A community based topic aggregation platform built on atproto
1package oauth
2
3import (
4 "crypto/aes"
5 "crypto/cipher"
6 "crypto/rand"
7 "encoding/base64"
8 "encoding/json"
9 "fmt"
10 "time"
11)
12
13// SealedSession represents the data sealed in a mobile session token
14type SealedSession struct {
15 DID string `json:"did"` // User's DID
16 SessionID string `json:"sid"` // Session identifier
17 ExpiresAt int64 `json:"exp"` // Unix timestamp when token expires
18}
19
20// SealSession creates an encrypted token containing session information.
21// The token is encrypted using AES-256-GCM and encoded as base64url.
22//
23// Token format: base64url(nonce || ciphertext || tag)
24// - nonce: 12 bytes (GCM standard nonce size)
25// - ciphertext: encrypted JSON payload
26// - tag: 16 bytes (GCM authentication tag)
27//
28// The sealed token can be safely given to mobile clients and used as
29// a reference to the server-side session without exposing sensitive data.
30func (c *OAuthClient) SealSession(did, sessionID string, ttl time.Duration) (string, error) {
31 if len(c.SealSecret) == 0 {
32 return "", fmt.Errorf("seal secret not configured")
33 }
34
35 if did == "" {
36 return "", fmt.Errorf("DID is required")
37 }
38
39 if sessionID == "" {
40 return "", fmt.Errorf("session ID is required")
41 }
42
43 // Create the session data
44 expiresAt := time.Now().Add(ttl).Unix()
45 session := SealedSession{
46 DID: did,
47 SessionID: sessionID,
48 ExpiresAt: expiresAt,
49 }
50
51 // Marshal to JSON
52 plaintext, err := json.Marshal(session)
53 if err != nil {
54 return "", fmt.Errorf("failed to marshal session: %w", err)
55 }
56
57 // Create AES cipher
58 block, err := aes.NewCipher(c.SealSecret)
59 if err != nil {
60 return "", fmt.Errorf("failed to create cipher: %w", err)
61 }
62
63 // Create GCM mode
64 gcm, err := cipher.NewGCM(block)
65 if err != nil {
66 return "", fmt.Errorf("failed to create GCM: %w", err)
67 }
68
69 // Generate random nonce
70 nonce := make([]byte, gcm.NonceSize())
71 if _, err := rand.Read(nonce); err != nil {
72 return "", fmt.Errorf("failed to generate nonce: %w", err)
73 }
74
75 // Encrypt and authenticate
76 // GCM.Seal appends the ciphertext and tag to the nonce
77 ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
78
79 // Encode as base64url (no padding)
80 token := base64.RawURLEncoding.EncodeToString(ciphertext)
81
82 return token, nil
83}
84
85// UnsealSession decrypts and validates a sealed session token.
86// Returns the session information if the token is valid and not expired.
87func (c *OAuthClient) UnsealSession(token string) (*SealedSession, error) {
88 if len(c.SealSecret) == 0 {
89 return nil, fmt.Errorf("seal secret not configured")
90 }
91
92 if token == "" {
93 return nil, fmt.Errorf("token is required")
94 }
95
96 // Decode from base64url
97 ciphertext, err := base64.RawURLEncoding.DecodeString(token)
98 if err != nil {
99 return nil, fmt.Errorf("invalid token encoding: %w", err)
100 }
101
102 // Create AES cipher
103 block, err := aes.NewCipher(c.SealSecret)
104 if err != nil {
105 return nil, fmt.Errorf("failed to create cipher: %w", err)
106 }
107
108 // Create GCM mode
109 gcm, err := cipher.NewGCM(block)
110 if err != nil {
111 return nil, fmt.Errorf("failed to create GCM: %w", err)
112 }
113
114 // Verify minimum size (nonce + tag)
115 nonceSize := gcm.NonceSize()
116 if len(ciphertext) < nonceSize {
117 return nil, fmt.Errorf("invalid token: too short")
118 }
119
120 // Extract nonce and ciphertext
121 nonce := ciphertext[:nonceSize]
122 ciphertextData := ciphertext[nonceSize:]
123
124 // Decrypt and authenticate
125 plaintext, err := gcm.Open(nil, nonce, ciphertextData, nil)
126 if err != nil {
127 return nil, fmt.Errorf("failed to decrypt token: %w", err)
128 }
129
130 // Unmarshal JSON
131 var session SealedSession
132 if err := json.Unmarshal(plaintext, &session); err != nil {
133 return nil, fmt.Errorf("failed to unmarshal session: %w", err)
134 }
135
136 // Validate required fields
137 if session.DID == "" {
138 return nil, fmt.Errorf("invalid session: missing DID")
139 }
140
141 if session.SessionID == "" {
142 return nil, fmt.Errorf("invalid session: missing session ID")
143 }
144
145 // Check expiration
146 now := time.Now().Unix()
147 if session.ExpiresAt <= now {
148 return nil, fmt.Errorf("token expired at %v", time.Unix(session.ExpiresAt, 0))
149 }
150
151 return &session, nil
152}