// Package handoff receives Home identity assertions. It binds each exchange to // the browser that started it and records use before the app issues a session. // Your app still decides team membership, account linking and item access. package handoff import ( "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "database/sql" "embed" "encoding/base64" "encoding/hex" "errors" "math" "net/url" "strings" "time" "unicode/utf8" "amadan.net/rastrillo/rastrillo/assertion" ) // Migrations must be applied by the app's migration runner before NewStore. // Keeping them here gives every consumer the same replay and browser binding. // //go:embed migrations/*.sql var Migrations embed.FS var ( ErrRefused = errors.New("handoff: refused") ErrStorage = errors.New("handoff: storage") ErrClock = errors.New("handoff: clock") ErrFull = errors.New("handoff: capacity") ErrConfig = errors.New("handoff: configuration") ) // A login can take several minutes when the user must open their mail client. const pendingLifetime = int64(600) // The inbox never needs to outlive the assertion it holds. const inboxLifetime = int64(120) // Retention matches the tolerated backward adjustment. Pruning earlier could // allow a token to be reused after the clock moves backwards. const clockTolerance = int64(30) const replayRetention = clockTolerance const freshAuthLifetime = int64(300) const freshPathPrefix = "oficina-handoff-fresh-v1:" // Caps bound unauthenticated navigation and verified callback storage. Pending // eviction only loses navigation; inbox eviction could disrupt a valid proof. const rowLimit = 10000 // Sealer must authenticate its purpose and plaintext. Apps supply their own // existing data-key implementation; this package never creates a storage key. type Sealer interface { Seal(purpose string, plaintext []byte) ([]byte, error) Open(purpose string, ciphertext []byte) ([]byte, error) } type Store struct { db *sql.DB sealer Sealer verifier *assertion.Verifier issuer, audience string } func NewStore(db *sql.DB, sealer Sealer, issuer, audience string, keys map[string][]byte) (*Store, error) { if db == nil || sealer == nil { return nil, ErrConfig } v, e := assertion.NewVerifier(issuer, audience, keys) if e != nil { return nil, ErrConfig } return &Store{db: db, sealer: sealer, verifier: v, issuer: issuer, audience: audience}, nil } func digest(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) } func jtiHash(c assertion.Claims) string { return digest(c.Issuer + "\x00" + c.ID) } func randomHandle() (string, error) { var b [32]byte if _, e := rand.Read(b[:]); e != nil { return "", ErrStorage } return base64.RawURLEncoding.EncodeToString(b[:]), nil } func validHandle(s string) bool { if len(s) != 43 { return false } b, e := base64.RawURLEncoding.Strict().DecodeString(s) return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s } // ValidNonce rejects alternate encodings, including CR/LF ignored by the base64 // decoder. The issuer uses this before retaining a browser's request. func ValidNonce(s string) bool { if len(s) != 22 { return false } b, e := base64.RawURLEncoding.Strict().DecodeString(s) return e == nil && len(b) == 16 && base64.RawURLEncoding.EncodeToString(b) == s } // SafePath accepts only local navigation outside the exchange itself. The // caller stores the returned value sealed, even when it looks harmless. func SafePath(s string) bool { if !utf8.ValidString(s) || strings.Contains(s, "#") || len(s) == 0 || len(s) > 2048 || !strings.HasPrefix(s, "/") || strings.HasPrefix(s, "//") { return false } for _, b := range []byte(s) { if b <= 32 || b == 127 || b == '\\' { return false } } u, e := url.ParseRequestURI(s) if e != nil || u.IsAbs() || u.Host != "" || u.Fragment != "" || u.Opaque != "" { return false } path := u.Path for _, b := range []byte(path) { if b < 32 || b == 127 { return false } } if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || strings.ContainsAny(path, "\\\r\n\x00") { return false } // Encoded or dot-segment aliases must not route back into authentication. for _, part := range strings.Split(path, "/") { if part == "." || part == ".." { return false } } return path != "/auth" && !strings.HasPrefix(path, "/auth/") && path != "/signin" && !strings.HasPrefix(path, "/signin/") && path != "/authorize" && !strings.HasPrefix(path, "/authorize/") } // begin takes the SQLite write lock before reading state, so several database // connections cannot race a capacity check or upgrade a stale read snapshot. func (s *Store) begin(ctx context.Context, now time.Time) (*sql.Tx, int64, int64, error) { n := now.Unix() if n <= 0 || n > math.MaxInt64-pendingLifetime { return nil, 0, 0, ErrClock } tx, e := s.db.BeginTx(ctx, nil) if e != nil { return nil, 0, 0, ErrStorage } result, e := tx.ExecContext(ctx, `UPDATE oficina_handoff_clock SET observed=MAX(observed,?) WHERE id=1 AND observed<=?`, n, n+clockTolerance) if e != nil { tx.Rollback() return nil, 0, 0, ErrStorage } rows, e := result.RowsAffected() if e != nil || rows != 1 { tx.Rollback() return nil, 0, 0, ErrClock } var fence int64 if e = tx.QueryRowContext(ctx, `SELECT observed FROM oficina_handoff_clock WHERE id=1`).Scan(&fence); e != nil { tx.Rollback() return nil, 0, 0, ErrStorage } for _, q := range []struct { sql string n int64 }{ {`DELETE FROM oficina_handoff_pending WHERE expires_at<=?`, n}, {`DELETE FROM oficina_handoff_inbox WHERE expires_at<=?`, n}, {`DELETE FROM oficina_handoff_replay WHERE expires_at= rowLimit { 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 { return "", "", ErrStorage } } 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 { return "", "", ErrStorage } if tx.Commit() != nil { return "", "", ErrStorage } return secret, nonce, nil } // cancel removes only the unpublished pending request after an app rejects its // binding. Concurrent eviction may already have removed it; that is success. func (s *Store) cancel(ctx context.Context, secret string) error { if !validHandle(secret) { return ErrRefused } if _, e := s.db.ExecContext(ctx, `DELETE FROM oficina_handoff_pending WHERE secret_hash=?`, digest(secret)); e != nil { return ErrStorage } return nil } // Receive deliberately needs no cookie: a cross-site POST may carry none. A // repeated assertion gets no new inbox row or cookie, even before Finish runs. func (s *Store) Receive(ctx context.Context, token string, now time.Time) (string, error) { claims, e := s.verifier.Verify(token, now) if e != nil { return "", ErrRefused } handle, e := randomHandle() if e != nil { return "", e } hash := digest(handle) jti := jtiHash(claims) sealed, e := s.sealer.Seal("handoff.token."+hash, []byte(token)) if e != nil { return "", ErrStorage } tx, n, _, e := s.begin(ctx, now) if e != nil { return "", e } defer tx.Rollback() var count int if e = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM oficina_handoff_replay WHERE jti_hash=?`, jti).Scan(&count); e != nil { return "", ErrStorage } if count != 0 { return "", ErrRefused } if e = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM oficina_handoff_inbox WHERE jti_hash=?`, jti).Scan(&count); e != nil { return "", ErrStorage } if count != 0 { return "", ErrRefused } if e = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM oficina_handoff_inbox`).Scan(&count); e != nil { return "", ErrStorage } if count >= rowLimit { return "", ErrFull } expires := claims.ExpiresAt if expires > n+inboxLifetime { expires = n + inboxLifetime } 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 { return "", ErrStorage } if tx.Commit() != nil { return "", ErrStorage } return handle, nil } // Finish commits replay consumption before returning an identity to the app. // existingSubject is resolved before calling: reading a session from inside // this transaction could deadlock an app's single-connection writer. func (s *Store) Finish(ctx context.Context, secret, handle, existingSubject string, now time.Time) (assertion.Claims, string, error) { zero := assertion.Claims{} if !validHandle(secret) || !validHandle(handle) { return zero, "", ErrRefused } tx, n, fence, e := s.begin(ctx, now) if e != nil { return zero, "", e } defer tx.Rollback() secretHash, handleHash := digest(secret), digest(handle) var nonceHash string var sealedPath, sealedToken []byte 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 { return zero, "", readError(e) } if e = tx.QueryRowContext(ctx, `SELECT sealed_token FROM oficina_handoff_inbox WHERE handle_hash=? AND expires_at>?`, handleHash, n).Scan(&sealedToken); e != nil { return zero, "", readError(e) } token, e := s.sealer.Open("handoff.token."+handleHash, sealedToken) if e != nil { return zero, "", ErrRefused } claims, e := s.verifier.Verify(string(token), now) if e != nil { return zero, "", ErrRefused } if subtle.ConstantTimeCompare([]byte(nonceHash), []byte(digest(claims.Nonce))) != 1 || existingSubject != "" && existingSubject != claims.Subject { return zero, "", ErrRefused } path, e := s.sealer.Open("handoff.path."+secretHash, sealedPath) if e != nil { return zero, "", ErrRefused } fresh := strings.HasPrefix(string(path), freshPathPrefix) if fresh { path = path[len(freshPathPrefix):] } if !SafePath(string(path)) { return zero, "", ErrRefused } result, e := tx.ExecContext(ctx, `INSERT INTO oficina_handoff_replay(jti_hash,expires_at) VALUES(?,?) ON CONFLICT DO NOTHING`, jtiHash(claims), claims.ExpiresAt) if e != nil { return zero, "", ErrStorage } rows, e := result.RowsAffected() if e != nil { return zero, "", ErrStorage } if rows != 1 { return zero, "", ErrRefused } 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}} { result, e = tx.ExecContext(ctx, q.sql, q.key) if e != nil { return zero, "", ErrStorage } rows, e = result.RowsAffected() if e != nil || rows != 1 { return zero, "", ErrStorage } } if tx.Commit() != nil { return zero, "", ErrStorage } // Burn an unusable fresh proof before refusing it: waiting or changing // handler configuration must never make it usable later. if fresh && (claims.AuthTime > n || fence-claims.AuthTime > freshAuthLifetime) { return zero, "", ErrRefused } return claims, string(path), nil } func readError(e error) error { if errors.Is(e, sql.ErrNoRows) { return ErrRefused } return ErrStorage }