// Package linkstore binds an existing local person to an independently verified // Home identity. It creates no people, sessions, membership or document grants. package linkstore import ( "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "database/sql" "embed" "encoding/base64" "encoding/binary" "encoding/hex" "errors" "net/url" "strings" "time" "unicode/utf8" "amadan.net/oficina/home/handoff" "amadan.net/oficina/home/handoff/seal" ) //go:embed migrations/*.sql var migrations embed.FS // Migrations must be applied before construction. var Migrations = migrations const SchemaNamespace = "oficina_connect_link" var ( ErrRefused = errors.New("home link: refused") ErrStorage = errors.New("home link: storage unavailable") ) const maxFlows = 10000 const maxAge = 300 // Local is supplied only after the caller verifies the actual local session // and its method-aware authentication time. Home assertions cannot supply it. // SessionToken is the actual cookie value, never a subject or a minted alias. type Local struct { PersonID int64 SessionToken string AuthTime time.Time } // Home contains already verified, consumed shared-handoff claims. The caller // must not construct it from query parameters or an unverified assertion. type Home struct { Issuer, Subject, Nonce string AuthTime time.Time } // Pending is safe server-side continuation state, not an authentication token. type Pending struct { PersonID int64 ReturnPath string HomeAuthTime time.Time } type Store struct { db *sql.DB seal *seal.Sealer origin, issuer string lookup LookupPerson bindingKey []byte namespace string } // New uses a dedicated handoff/storage DEK, never an application content key. // Apply Schema first, then call Sweep at boot before accepting requests. func New(db *sql.DB, dek []byte, origin, issuer, namespace string, lookup LookupPerson) (*Store, error) { if lookup == nil || db == nil || !validOrigin(origin) || !validOrigin(issuer) { return nil, ErrRefused } sealer, err := seal.New(dek, namespace) if err != nil { return nil, ErrRefused } return &Store{db: db, seal: sealer, origin: origin, issuer: issuer, lookup: lookup, bindingKey: append([]byte(nil), dek...), namespace: namespace}, nil } func validOrigin(v string) bool { u, err := url.Parse(v) return err == nil && len(v) <= 2048 && u.Scheme == "https" && u.Host != "" && u.Hostname() != "" && u.User == nil && u.Path == "" && u.RawQuery == "" && !u.ForceQuery && u.Fragment == "" && u.Opaque == "" && u.String() == v && !strings.ContainsAny(v, "\\\r\n\t ") } func hash(v string) string { sum := sha256.Sum256([]byte(v)); return hex.EncodeToString(sum[:]) } func secretHash(v string) (string, bool) { if len(v) != 43 { return "", false } b, err := base64.RawURLEncoding.Strict().DecodeString(v) return hash(v), err == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == v } func newSecret() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", ErrStorage } return base64.RawURLEncoding.EncodeToString(b), nil } func fresh(at int64, now, high int64) bool { return at > 0 && at <= now && high-at <= maxAge } func validLocal(local Local, now, high int64) bool { return local.PersonID > 0 && local.SessionToken != "" && len(local.SessionToken) <= 4096 && fresh(local.AuthTime.Unix(), now, high) } func validSubject(v string) bool { if v == "" || len(v) > 512 || !utf8.ValidString(v) { return false } for _, r := range v { if r <= 32 || r == 127 { return false } } return true } // begin obtains the write lock before any reads, including capacity checks. // A persisted clock prevents stale evidence becoming fresh after a restart or // a clock rollback. Match the shared handoff's 30-second rollback tolerance. func (s *Store) begin(ctx context.Context, now time.Time) (*sql.Tx, int64, error) { if now.Unix() <= 0 { return nil, 0, ErrRefused } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return nil, 0, ErrStorage } if _, err = tx.ExecContext(ctx, "UPDATE home_link_clock SET high_water = MAX(high_water, ?) WHERE id = 1", now.Unix()); err != nil { tx.Rollback() return nil, 0, ErrStorage } var high int64 if err = tx.QueryRowContext(ctx, "SELECT high_water FROM home_link_clock WHERE id = 1").Scan(&high); err != nil { tx.Rollback() return nil, 0, ErrStorage } if now.Unix() < high-30 { tx.Rollback() return nil, 0, ErrRefused } return tx, high, nil } func refuse(tx *sql.Tx) error { if err := tx.Commit(); err != nil { return ErrStorage } return ErrRefused } func commit(tx *sql.Tx) error { if err := tx.Commit(); err != nil { return ErrStorage } return nil } func sweep(ctx context.Context, tx *sql.Tx, high int64) error { if _, err := tx.ExecContext(ctx, "DELETE FROM home_link_flows WHERE expires_at <= ?", high); err != nil { return ErrStorage } return nil } func (s *Store) Sweep(ctx context.Context, now time.Time) error { tx, high, err := s.begin(ctx, now) if err != nil { return err } defer tx.Rollback() if err := sweep(ctx, tx, high); err != nil { return err } return commit(tx) } // Resolve reads the current existing person, never EnsurePerson or a cached // address. A deleted or unlinked person is refused without creating anything. func (s *Store) Resolve(ctx context.Context, issuer, subject string) (int64, string, error) { if issuer != s.issuer || !validSubject(subject) { return 0, "", ErrRefused } var id int64 var address, binding string err := s.db.QueryRowContext(ctx, `SELECT person_id, person_binding FROM home_identity_links WHERE issuer = ? AND subject = ?`, issuer, subject).Scan(&id, &binding) if errors.Is(err, sql.ErrNoRows) { return 0, "", ErrRefused } if err != nil { return 0, "", ErrStorage } address, err = s.lookup(ctx, s.db, id) if errors.Is(err, sql.ErrNoRows) { return 0, "", ErrRefused } if err != nil { return 0, "", ErrStorage } if id <= 0 || address == "" || !hmac.Equal([]byte(binding), []byte(s.personBinding(id, address))) { return 0, "", ErrRefused } return id, address, nil } func safePath(v string) string { if handoff.SafePath(v) { return v } return "/" } // Queryer permits lookups inside the same transaction as link writes. type Queryer interface { QueryRowContext(context.Context, string, ...any) *sql.Row } // LookupPerson returns the canonical verified local address, never a display name. // IDs must never be reused; deletion must remove links in the same transaction. It must never // create one, and must use q rather than opening a separate transaction. // Missing or currently inadmissible people return sql.ErrNoRows. type LookupPerson func(ctx context.Context, q Queryer, id int64) (string, error) // personBinding prevents a stale numeric link from resolving a different address. // Length-prefixed fields keep the namespace, origin, ID and address unambiguous. func (s *Store) personBinding(id int64, address string) string { m := hmac.New(sha256.New, s.bindingKey) for _, field := range []string{"home-connect/person-binding/v1", s.namespace, s.origin, s.issuer} { _ = binary.Write(m, binary.BigEndian, uint64(len(field))) _, _ = m.Write([]byte(field)) } _ = binary.Write(m, binary.BigEndian, id) _ = binary.Write(m, binary.BigEndian, uint64(len(address))) _, _ = m.Write([]byte(address)) return hex.EncodeToString(m.Sum(nil)) }