oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1// Package handoff receives Home identity assertions. It binds each exchange to
2// the browser that started it and records use before the app issues a session.
3// Your app still decides team membership, account linking and item access.
4package handoff
5
6import (
7 "context"
8 "crypto/rand"
9 "crypto/sha256"
10 "crypto/subtle"
11 "database/sql"
12 "embed"
13 "encoding/base64"
14 "encoding/hex"
15 "errors"
16 "math"
17 "net/url"
18 "strings"
19 "time"
20 "unicode/utf8"
21
22 "amadan.net/rastrillo/rastrillo/assertion"
23)
24
25// Migrations must be applied by the app's migration runner before NewStore.
26// Keeping them here gives every consumer the same replay and browser binding.
27//
28//go:embed migrations/*.sql
29var Migrations embed.FS
30
31var (
32 ErrRefused = errors.New("handoff: refused")
33 ErrStorage = errors.New("handoff: storage")
34 ErrClock = errors.New("handoff: clock")
35 ErrFull = errors.New("handoff: capacity")
36 ErrConfig = errors.New("handoff: configuration")
37)
38
39// A login can take several minutes when the user must open their mail client.
40const pendingLifetime = int64(600)
41
42// The inbox never needs to outlive the assertion it holds.
43const inboxLifetime = int64(120)
44
45// Retention matches the tolerated backward adjustment. Pruning earlier could
46// allow a token to be reused after the clock moves backwards.
47const clockTolerance = int64(30)
48const replayRetention = clockTolerance
49const freshAuthLifetime = int64(300)
50const freshPathPrefix = "oficina-handoff-fresh-v1:"
51
52// Caps bound unauthenticated navigation and verified callback storage. Pending
53// eviction only loses navigation; inbox eviction could disrupt a valid proof.
54const rowLimit = 10000
55
56// Sealer must authenticate its purpose and plaintext. Apps supply their own
57// existing data-key implementation; this package never creates a storage key.
58type Sealer interface {
59 Seal(purpose string, plaintext []byte) ([]byte, error)
60 Open(purpose string, ciphertext []byte) ([]byte, error)
61}
62
63type Store struct {
64 db *sql.DB
65 sealer Sealer
66 verifier *assertion.Verifier
67 issuer, audience string
68}
69
70func NewStore(db *sql.DB, sealer Sealer, issuer, audience string, keys map[string][]byte) (*Store, error) {
71 if db == nil || sealer == nil {
72 return nil, ErrConfig
73 }
74 v, e := assertion.NewVerifier(issuer, audience, keys)
75 if e != nil {
76 return nil, ErrConfig
77 }
78 return &Store{db: db, sealer: sealer, verifier: v, issuer: issuer, audience: audience}, nil
79}
80
81func digest(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
82func jtiHash(c assertion.Claims) string { return digest(c.Issuer + "\x00" + c.ID) }
83func randomHandle() (string, error) {
84 var b [32]byte
85 if _, e := rand.Read(b[:]); e != nil {
86 return "", ErrStorage
87 }
88 return base64.RawURLEncoding.EncodeToString(b[:]), nil
89}
90func validHandle(s string) bool {
91 if len(s) != 43 {
92 return false
93 }
94 b, e := base64.RawURLEncoding.Strict().DecodeString(s)
95 return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s
96}
97
98// ValidNonce rejects alternate encodings, including CR/LF ignored by the base64
99// decoder. The issuer uses this before retaining a browser's request.
100func ValidNonce(s string) bool {
101 if len(s) != 22 {
102 return false
103 }
104 b, e := base64.RawURLEncoding.Strict().DecodeString(s)
105 return e == nil && len(b) == 16 && base64.RawURLEncoding.EncodeToString(b) == s
106}
107
108// SafePath accepts only local navigation outside the exchange itself. The
109// caller stores the returned value sealed, even when it looks harmless.
110func SafePath(s string) bool {
111 if !utf8.ValidString(s) || strings.Contains(s, "#") || len(s) == 0 || len(s) > 2048 || !strings.HasPrefix(s, "/") || strings.HasPrefix(s, "//") {
112 return false
113 }
114 for _, b := range []byte(s) {
115 if b <= 32 || b == 127 || b == '\\' {
116 return false
117 }
118 }
119 u, e := url.ParseRequestURI(s)
120 if e != nil || u.IsAbs() || u.Host != "" || u.Fragment != "" || u.Opaque != "" {
121 return false
122 }
123 path := u.Path
124 for _, b := range []byte(path) {
125 if b < 32 || b == 127 {
126 return false
127 }
128 }
129 if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || strings.ContainsAny(path, "\\\r\n\x00") {
130 return false
131 }
132 // Encoded or dot-segment aliases must not route back into authentication.
133 for _, part := range strings.Split(path, "/") {
134 if part == "." || part == ".." {
135 return false
136 }
137 }
138 return path != "/auth" && !strings.HasPrefix(path, "/auth/") && path != "/signin" && !strings.HasPrefix(path, "/signin/") && path != "/authorize" && !strings.HasPrefix(path, "/authorize/")
139}
140
141// begin takes the SQLite write lock before reading state, so several database
142// connections cannot race a capacity check or upgrade a stale read snapshot.
143func (s *Store) begin(ctx context.Context, now time.Time) (*sql.Tx, int64, int64, error) {
144 n := now.Unix()
145 if n <= 0 || n > math.MaxInt64-pendingLifetime {
146 return nil, 0, 0, ErrClock
147 }
148 tx, e := s.db.BeginTx(ctx, nil)
149 if e != nil {
150 return nil, 0, 0, ErrStorage
151 }
152 result, e := tx.ExecContext(ctx, `UPDATE oficina_handoff_clock SET observed=MAX(observed,?) WHERE id=1 AND observed<=?`, n, n+clockTolerance)
153 if e != nil {
154 tx.Rollback()
155 return nil, 0, 0, ErrStorage
156 }
157 rows, e := result.RowsAffected()
158 if e != nil || rows != 1 {
159 tx.Rollback()
160 return nil, 0, 0, ErrClock
161 }
162 var fence int64
163 if e = tx.QueryRowContext(ctx, `SELECT observed FROM oficina_handoff_clock WHERE id=1`).Scan(&fence); e != nil {
164 tx.Rollback()
165 return nil, 0, 0, ErrStorage
166 }
167 for _, q := range []struct {
168 sql string
169 n int64
170 }{
171 {`DELETE FROM oficina_handoff_pending WHERE expires_at<=?`, n},
172 {`DELETE FROM oficina_handoff_inbox WHERE expires_at<=?`, n},
173 {`DELETE FROM oficina_handoff_replay WHERE expires_at<?`, fence - replayRetention},
174 } {
175 if _, e = tx.ExecContext(ctx, q.sql, q.n); e != nil {
176 tx.Rollback()
177 return nil, 0, 0, ErrStorage
178 }
179 }
180 return tx, n, fence, nil
181}
182
183func (s *Store) Sweep(ctx context.Context, now time.Time) error {
184 tx, _, _, e := s.begin(ctx, now)
185 if e != nil {
186 return e
187 }
188 defer tx.Rollback()
189 if tx.Commit() != nil {
190 return ErrStorage
191 }
192 return nil
193}
194
195// Start replaces this browser's previous pending request. Browser secrets and
196// nonces are returned to the caller, but only their hashes enter the database.
197func (s *Store) Start(ctx context.Context, path, previous string, now time.Time) (secret, nonce string, err error) {
198 return s.start(ctx, path, previous, now, false)
199}
200
201// StartFresh binds a five-minute authentication requirement into sealed intent.
202// Finish enforces it even when an ordinary consumer receives the callback.
203func (s *Store) StartFresh(ctx context.Context, path, previous string, now time.Time) (secret, nonce string, err error) {
204 return s.start(ctx, path, previous, now, true)
205}
206
207func (s *Store) start(ctx context.Context, path, previous string, now time.Time, fresh bool) (secret, nonce string, err error) {
208 if !SafePath(path) {
209 return "", "", ErrRefused
210 }
211 secret, err = randomHandle()
212 if err != nil {
213 return "", "", err
214 }
215 nonce, err = assertion.RandomID()
216 if err != nil {
217 return "", "", ErrStorage
218 }
219 if fresh {
220 path = freshPathPrefix + path
221 }
222 hash := digest(secret)
223 sealed, e := s.sealer.Seal("handoff.path."+hash, []byte(path))
224 if e != nil {
225 return "", "", ErrStorage
226 }
227 tx, n, _, e := s.begin(ctx, now)
228 if e != nil {
229 return "", "", e
230 }
231 defer tx.Rollback()
232 if validHandle(previous) {
233 if _, e = tx.ExecContext(ctx, `DELETE FROM oficina_handoff_pending WHERE secret_hash=?`, digest(previous)); e != nil {
234 return "", "", ErrStorage
235 }
236 }
237 var count int
238 if e = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM oficina_handoff_pending`).Scan(&count); e != nil {
239 return "", "", ErrStorage
240 }
241 if count >= rowLimit {
242 if _, e = tx.ExecContext(ctx, `DELETE FROM oficina_handoff_pending WHERE secret_hash IN (SELECT secret_hash FROM oficina_handoff_pending ORDER BY created_at,secret_hash LIMIT ?)`, count-rowLimit+1); e != nil {
243 return "", "", ErrStorage
244 }
245 }
246 if _, e = tx.ExecContext(ctx, `INSERT INTO oficina_handoff_pending(secret_hash,nonce_hash,sealed_path,created_at,expires_at) VALUES(?,?,?,?,?)`, hash, digest(nonce), sealed, n, n+pendingLifetime); e != nil {
247 return "", "", ErrStorage
248 }
249 if tx.Commit() != nil {
250 return "", "", ErrStorage
251 }
252 return secret, nonce, nil
253}
254
255// cancel removes only the unpublished pending request after an app rejects its
256// binding. Concurrent eviction may already have removed it; that is success.
257func (s *Store) cancel(ctx context.Context, secret string) error {
258 if !validHandle(secret) {
259 return ErrRefused
260 }
261 if _, e := s.db.ExecContext(ctx, `DELETE FROM oficina_handoff_pending WHERE secret_hash=?`, digest(secret)); e != nil {
262 return ErrStorage
263 }
264 return nil
265}
266
267// Receive deliberately needs no cookie: a cross-site POST may carry none. A
268// repeated assertion gets no new inbox row or cookie, even before Finish runs.
269func (s *Store) Receive(ctx context.Context, token string, now time.Time) (string, error) {
270 claims, e := s.verifier.Verify(token, now)
271 if e != nil {
272 return "", ErrRefused
273 }
274 handle, e := randomHandle()
275 if e != nil {
276 return "", e
277 }
278 hash := digest(handle)
279 jti := jtiHash(claims)
280 sealed, e := s.sealer.Seal("handoff.token."+hash, []byte(token))
281 if e != nil {
282 return "", ErrStorage
283 }
284 tx, n, _, e := s.begin(ctx, now)
285 if e != nil {
286 return "", e
287 }
288 defer tx.Rollback()
289 var count int
290 if e = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM oficina_handoff_replay WHERE jti_hash=?`, jti).Scan(&count); e != nil {
291 return "", ErrStorage
292 }
293 if count != 0 {
294 return "", ErrRefused
295 }
296 if e = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM oficina_handoff_inbox WHERE jti_hash=?`, jti).Scan(&count); e != nil {
297 return "", ErrStorage
298 }
299 if count != 0 {
300 return "", ErrRefused
301 }
302 if e = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM oficina_handoff_inbox`).Scan(&count); e != nil {
303 return "", ErrStorage
304 }
305 if count >= rowLimit {
306 return "", ErrFull
307 }
308 expires := claims.ExpiresAt
309 if expires > n+inboxLifetime {
310 expires = n + inboxLifetime
311 }
312 if _, e = tx.ExecContext(ctx, `INSERT INTO oficina_handoff_inbox(handle_hash,jti_hash,sealed_token,created_at,expires_at) VALUES(?,?,?,?,?)`, hash, jti, sealed, n, expires); e != nil {
313 return "", ErrStorage
314 }
315 if tx.Commit() != nil {
316 return "", ErrStorage
317 }
318 return handle, nil
319}
320
321// Finish commits replay consumption before returning an identity to the app.
322// existingSubject is resolved before calling: reading a session from inside
323// this transaction could deadlock an app's single-connection writer.
324func (s *Store) Finish(ctx context.Context, secret, handle, existingSubject string, now time.Time) (assertion.Claims, string, error) {
325 zero := assertion.Claims{}
326 if !validHandle(secret) || !validHandle(handle) {
327 return zero, "", ErrRefused
328 }
329 tx, n, fence, e := s.begin(ctx, now)
330 if e != nil {
331 return zero, "", e
332 }
333 defer tx.Rollback()
334 secretHash, handleHash := digest(secret), digest(handle)
335 var nonceHash string
336 var sealedPath, sealedToken []byte
337 if e = tx.QueryRowContext(ctx, `SELECT nonce_hash,sealed_path FROM oficina_handoff_pending WHERE secret_hash=? AND expires_at>?`, secretHash, n).Scan(&nonceHash, &sealedPath); e != nil {
338 return zero, "", readError(e)
339 }
340 if e = tx.QueryRowContext(ctx, `SELECT sealed_token FROM oficina_handoff_inbox WHERE handle_hash=? AND expires_at>?`, handleHash, n).Scan(&sealedToken); e != nil {
341 return zero, "", readError(e)
342 }
343 token, e := s.sealer.Open("handoff.token."+handleHash, sealedToken)
344 if e != nil {
345 return zero, "", ErrRefused
346 }
347 claims, e := s.verifier.Verify(string(token), now)
348 if e != nil {
349 return zero, "", ErrRefused
350 }
351 if subtle.ConstantTimeCompare([]byte(nonceHash), []byte(digest(claims.Nonce))) != 1 || existingSubject != "" && existingSubject != claims.Subject {
352 return zero, "", ErrRefused
353 }
354 path, e := s.sealer.Open("handoff.path."+secretHash, sealedPath)
355 if e != nil {
356 return zero, "", ErrRefused
357 }
358 fresh := strings.HasPrefix(string(path), freshPathPrefix)
359 if fresh {
360 path = path[len(freshPathPrefix):]
361 }
362 if !SafePath(string(path)) {
363 return zero, "", ErrRefused
364 }
365 result, e := tx.ExecContext(ctx, `INSERT INTO oficina_handoff_replay(jti_hash,expires_at) VALUES(?,?) ON CONFLICT DO NOTHING`, jtiHash(claims), claims.ExpiresAt)
366 if e != nil {
367 return zero, "", ErrStorage
368 }
369 rows, e := result.RowsAffected()
370 if e != nil {
371 return zero, "", ErrStorage
372 }
373 if rows != 1 {
374 return zero, "", ErrRefused
375 }
376 for _, q := range []struct{ sql, key string }{{`DELETE FROM oficina_handoff_pending WHERE secret_hash=?`, secretHash}, {`DELETE FROM oficina_handoff_inbox WHERE handle_hash=?`, handleHash}} {
377 result, e = tx.ExecContext(ctx, q.sql, q.key)
378 if e != nil {
379 return zero, "", ErrStorage
380 }
381 rows, e = result.RowsAffected()
382 if e != nil || rows != 1 {
383 return zero, "", ErrStorage
384 }
385 }
386 if tx.Commit() != nil {
387 return zero, "", ErrStorage
388 }
389 // Burn an unusable fresh proof before refusing it: waiting or changing
390 // handler configuration must never make it usable later.
391 if fresh && (claims.AuthTime > n || fence-claims.AuthTime > freshAuthLifetime) {
392 return zero, "", ErrRefused
393 }
394 return claims, string(path), nil
395}
396func readError(e error) error {
397 if errors.Is(e, sql.ErrNoRows) {
398 return ErrRefused
399 }
400 return ErrStorage
401}
402