package linkstore import ( "bytes" "context" "crypto/hmac" "database/sql" "encoding/json" "errors" "io" "time" "amadan.net/oficina/home/handoff" ) type flow struct { PersonBinding string `json:"person_binding"` Version int `json:"v"` PersonID int64 `json:"person"` SessionHash string `json:"session"` LocalAuth int64 `json:"local_auth"` Issuer string `json:"issuer"` NonceHash string `json:"nonce,omitempty"` Subject string `json:"subject,omitempty"` HomeAuth int64 `json:"home_auth,omitempty"` ReturnPath string `json:"return"` Expires int64 `json:"expires"` } func (s *Store) purpose(stage, id string) string { return "identity-link/v1/" + stage + "/" + id + "/" + s.origin } func (s *Store) put(ctx context.Context, tx *sql.Tx, stage, id string, f flow) error { raw, err := json.Marshal(f) if err != nil { return ErrStorage } sealed, err := s.seal.Seal(s.purpose(stage, id), raw) if err != nil { return ErrStorage } _, err = tx.ExecContext(ctx, "INSERT INTO home_link_flows (id_hash, stage, expires_at, payload) VALUES (?, ?, ?, ?)", id, stage, f.Expires, sealed) if err != nil { return ErrStorage } return nil } // load deletes a presented row before interpreting it when consume is true. // Callers commit refusals, so a rejected conflict or mixed-up browser cannot // reuse its proof. Storage failures roll back and never install a mapping. func (s *Store) load(ctx context.Context, tx *sql.Tx, stage, id string, high int64, consume bool) (flow, error) { var raw []byte var expires int64 err := tx.QueryRowContext(ctx, "SELECT expires_at, payload FROM home_link_flows WHERE id_hash = ? AND stage = ?", id, stage).Scan(&expires, &raw) if errors.Is(err, sql.ErrNoRows) { return flow{}, ErrRefused } if err != nil { return flow{}, ErrStorage } if consume { if _, err := tx.ExecContext(ctx, "DELETE FROM home_link_flows WHERE id_hash = ? AND stage = ?", id, stage); err != nil { return flow{}, ErrStorage } } if len(raw) > 8192 || expires <= high { return flow{}, ErrRefused } opened, err := s.seal.Open(s.purpose(stage, id), raw) if err != nil { return flow{}, ErrRefused } var f flow dec := json.NewDecoder(bytes.NewReader(opened)) dec.DisallowUnknownFields() if dec.Decode(&f) != nil || dec.Decode(new(any)) != io.EOF { return flow{}, ErrRefused } if f.Version != 1 || f.Expires != expires || f.Issuer != s.issuer || !handoff.SafePath(f.ReturnPath) { return flow{}, ErrRefused } return f, nil } func (s *Store) matches(f flow, local Local, now, high int64) bool { return validLocal(local, now, high) && f.PersonID == local.PersonID && f.SessionHash == hash(local.SessionToken) && f.LocalAuth == local.AuthTime.Unix() && fresh(f.LocalAuth, now, high) } func (s *Store) currentBinding(ctx context.Context, tx *sql.Tx, id int64) (string, error) { label, err := s.lookup(ctx, tx, id) if errors.Is(err, sql.ErrNoRows) { return "", nil } if err != nil { return "", ErrStorage } if label == "" { return "", nil } return s.personBinding(id, label), nil } // Start is called only by an explicit same-origin local-account link POST. // The returned secret belongs in a host-only Secure/HttpOnly/Lax cookie. func (s *Store) Start(ctx context.Context, local Local, returnPath string, now time.Time) (string, 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 } if !validLocal(local, now.Unix(), high) { return "", refuse(tx) } binding, err := s.currentBinding(ctx, tx, local.PersonID) if err != nil { return "", err } if binding == "" { return "", refuse(tx) } var count int if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM home_link_flows").Scan(&count); err != nil { return "", ErrStorage } if count >= maxFlows { return "", refuse(tx) } secret, err := newSecret() if err != nil { return "", err } expires := min(now.Unix()+maxAge, local.AuthTime.Unix()+maxAge) if expires <= high { return "", refuse(tx) } f := flow{PersonBinding: binding, Version: 1, PersonID: local.PersonID, SessionHash: hash(local.SessionToken), LocalAuth: local.AuthTime.Unix(), Issuer: s.issuer, ReturnPath: safePath(returnPath), Expires: expires} if err := s.put(ctx, tx, "init", hash(secret), f); err != nil { return "", err } if err := commit(tx); err != nil { return "", err } return secret, nil } // BindNonce is the shared consumer's start hook. It accepts one generated // nonce exactly once, before that consumer sets its cookie or redirects. func (s *Store) BindNonce(ctx context.Context, secret string, local Local, nonce string, now time.Time) error { id, ok := secretHash(secret) if !ok { return ErrRefused } tx, high, err := s.begin(ctx, now) if err != nil { return err } defer tx.Rollback() f, err := s.load(ctx, tx, "init", id, high, true) if errors.Is(err, ErrRefused) { return refuse(tx) } if err != nil { return err } if !s.matches(f, local, now.Unix(), high) || !handoff.ValidNonce(nonce) || f.NonceHash != "" { return refuse(tx) } binding, err := s.currentBinding(ctx, tx, local.PersonID) if err != nil { return err } if binding == "" || !hmac.Equal([]byte(binding), []byte(f.PersonBinding)) { return refuse(tx) } f.NonceHash = hash(nonce) if err := s.put(ctx, tx, "init", id, f); err != nil { return err } return commit(tx) } // Complete consumes the initiation after the shared consumer has consumed and // verified the Home assertion. It keeps the local Docs session intact. func (s *Store) Complete(ctx context.Context, secret string, local Local, home Home, now time.Time) (string, error) { id, ok := secretHash(secret) if !ok { return "", ErrRefused } tx, high, err := s.begin(ctx, now) if err != nil { return "", err } defer tx.Rollback() f, err := s.load(ctx, tx, "init", id, high, true) if errors.Is(err, ErrRefused) { return "", refuse(tx) } if err != nil { return "", err } if !s.matches(f, local, now.Unix(), high) || !handoff.ValidNonce(home.Nonce) || f.NonceHash != hash(home.Nonce) || home.Issuer != s.issuer || !validSubject(home.Subject) || !fresh(home.AuthTime.Unix(), now.Unix(), high) { return "", refuse(tx) } binding, err := s.currentBinding(ctx, tx, local.PersonID) if err != nil { return "", err } if binding == "" || !hmac.Equal([]byte(binding), []byte(f.PersonBinding)) { return "", refuse(tx) } pending, err := newSecret() if err != nil { return "", err } f.Subject, f.HomeAuth = home.Subject, home.AuthTime.Unix() f.Expires = min(f.Expires, f.HomeAuth+maxAge) if f.Expires <= high { return "", refuse(tx) } if err := s.put(ctx, tx, "pending", hash(pending), f); err != nil { return "", err } if err := commit(tx); err != nil { return "", err } return pending, nil } func validPending(f flow, now, high int64) bool { return f.NonceHash != "" && validSubject(f.Subject) && fresh(f.HomeAuth, now, high) } func pendingView(f flow) Pending { return Pending{PersonID: f.PersonID, ReturnPath: f.ReturnPath, HomeAuthTime: time.Unix(f.HomeAuth, 0)} } // Pending reads a still-valid continuation for its current local session. // Invalid presented state is consumed; reading valid state creates no link. func (s *Store) Pending(ctx context.Context, secret string, local Local, now time.Time) (Pending, error) { id, ok := secretHash(secret) if !ok { return Pending{}, ErrRefused } tx, high, err := s.begin(ctx, now) if err != nil { return Pending{}, err } defer tx.Rollback() f, err := s.load(ctx, tx, "pending", id, high, true) if errors.Is(err, ErrRefused) { return Pending{}, refuse(tx) } if err != nil { return Pending{}, err } if !s.matches(f, local, now.Unix(), high) || !validPending(f, now.Unix(), high) { return Pending{}, refuse(tx) } binding, err := s.currentBinding(ctx, tx, local.PersonID) if err != nil { return Pending{}, err } if binding == "" || !hmac.Equal([]byte(binding), []byte(f.PersonBinding)) { return Pending{}, refuse(tx) } if err := s.put(ctx, tx, "pending", id, f); err != nil { return Pending{}, err } if err := commit(tx); err != nil { return Pending{}, err } return pendingView(f), nil } // Confirm requires an explicit same-origin POST. The proof deletion and unique // mapping write commit together. A separate proof for the same mapping is // idempotent; a reused cookie or conflicting mapping is consumed and refused. func (s *Store) Confirm(ctx context.Context, secret string, local Local, now time.Time) (Pending, error) { id, ok := secretHash(secret) if !ok { return Pending{}, ErrRefused } tx, high, err := s.begin(ctx, now) if err != nil { return Pending{}, err } defer tx.Rollback() f, err := s.load(ctx, tx, "pending", id, high, true) if errors.Is(err, ErrRefused) { return Pending{}, refuse(tx) } if err != nil { return Pending{}, err } if !s.matches(f, local, now.Unix(), high) || !validPending(f, now.Unix(), high) { return Pending{}, refuse(tx) } binding, err := s.currentBinding(ctx, tx, local.PersonID) if err != nil { return Pending{}, err } if binding == "" || !hmac.Equal([]byte(binding), []byte(f.PersonBinding)) { return Pending{}, refuse(tx) } _, err = tx.ExecContext(ctx, "INSERT INTO home_identity_links (issuer, subject, person_id, person_binding) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING", s.issuer, f.Subject, f.PersonID, f.PersonBinding) if err != nil { return Pending{}, ErrStorage } var person int64 var storedBinding string err = tx.QueryRowContext(ctx, "SELECT person_id, person_binding FROM home_identity_links WHERE issuer = ? AND subject = ?", s.issuer, f.Subject).Scan(&person, &storedBinding) if errors.Is(err, sql.ErrNoRows) { return Pending{}, refuse(tx) } if err != nil { return Pending{}, ErrStorage } if person != f.PersonID || !hmac.Equal([]byte(storedBinding), []byte(f.PersonBinding)) { return Pending{}, refuse(tx) } if err := commit(tx); err != nil { return Pending{}, err } return pendingView(f), nil }