| 1 | package home |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/hmac" |
| 6 | "crypto/sha256" |
| 7 | "database/sql" |
| 8 | "encoding/hex" |
| 9 | "errors" |
| 10 | "log/slog" |
| 11 | "strings" |
| 12 | "time" |
| 13 | |
| 14 | "amadan.net/rastrillo/rastrillo/db" |
| 15 | |
| 16 | "amadan.net/oficina/home/internal/home/seal" |
| 17 | ) |
| 18 | |
| 19 | // sealPurposeAddress is the HKDF purpose a link's address is sealed |
| 20 | // under. A value sealed under one purpose cannot be opened under |
| 21 | // another, so what this catches is a store handed the wrong purpose — |
| 22 | // invisible to the type system, since every sealed value is just opaque |
| 23 | // bytes, and invisible to any test that round-trips through the purpose |
| 24 | // it wrote. |
| 25 | const sealPurposeAddress = "signin.address" |
| 26 | |
| 27 | // linkTimeFormat pins how expires_at is written AND compared. |
| 28 | // |
| 29 | // The comparison is a string comparison, so both sides must be written |
| 30 | // the same way. A value carrying a zone offset sorts differently from a |
| 31 | // Z-suffixed one of the same instant, which would silently accept |
| 32 | // expired links. UTC and RFC3339 everywhere, never local time. |
| 33 | const linkTimeFormat = time.RFC3339 |
| 34 | |
| 35 | // dayFormat coarsens a person's created_at to the day. |
| 36 | // |
| 37 | // This is this plan's choice, not a rule inherited from the spec: §3.3's |
| 38 | // coarsening is explicitly about publication rather than storage. It is |
| 39 | // still right here — it is strictly less information, and |
| 40 | // people.created_at has no freshness role that a time of day would |
| 41 | // serve. |
| 42 | const dayFormat = "2006-01-02" |
| 43 | |
| 44 | // blindIndex is ruling 3: HMAC(pepper, lowercased address). |
| 45 | // |
| 46 | // The pepper never leaves the home (ruling 4). One on every instance |
| 47 | // would turn a single instance compromise into an offline dictionary |
| 48 | // attack against the whole suite directory. |
| 49 | // |
| 50 | // A tension worth stating rather than hiding. §3.1 does not express a |
| 51 | // preference, it makes a claim: "the index uses an expensive KDF rather |
| 52 | // than a bare HMAC, pricing the dictionary run rather than preventing |
| 53 | // it." Ruling 3, in §1's settled table, says HMAC. Both cannot be true |
| 54 | // of this function, and it implements the ruling, so §3.1's sentence is |
| 55 | // currently false about the code. |
| 56 | // |
| 57 | // Filed as an open question in the spec's §3.1 — see the note there — |
| 58 | // because changing either one is a §1 row, which is Paul's to rule. |
| 59 | func blindIndex(pepper []byte, address string) string { |
| 60 | m := hmac.New(sha256.New, pepper) |
| 61 | m.Write([]byte(strings.ToLower(address))) |
| 62 | return hex.EncodeToString(m.Sum(nil)) |
| 63 | } |
| 64 | |
| 65 | // linkStore is the signin.LinkStore this app hands to signin.Flow. It |
| 66 | // seals before it writes and takes exactly once. |
| 67 | type linkStore struct { |
| 68 | db *db.DB |
| 69 | sealer *seal.Sealer |
| 70 | logger *slog.Logger |
| 71 | } |
| 72 | |
| 73 | // PutLink seals the address and stores the link. |
| 74 | // |
| 75 | // Seal before the insert, never "insert then clean up": Litestream ships |
| 76 | // WAL frames continuously, so a plaintext write reaches the bucket |
| 77 | // before any delete could remove it. |
| 78 | func (s *linkStore) PutLink(ctx context.Context, hash, address, purpose string, expires time.Time) error { |
| 79 | sealed, err := s.sealer.Seal(sealPurposeAddress, []byte(address)) |
| 80 | if err != nil { |
| 81 | return err |
| 82 | } |
| 83 | _, err = s.db.Writer().ExecContext(ctx, |
| 84 | `INSERT INTO signin_links (token_hash, sealed_address, purpose, expires_at) |
| 85 | VALUES (?, ?, ?, ?)`, |
| 86 | hash, sealed, purpose, expires.UTC().Format(linkTimeFormat)) |
| 87 | if err != nil { |
| 88 | return err |
| 89 | } |
| 90 | |
| 91 | // Sweep after the write, and never fail the write on it. Sealed rows |
| 92 | // are not a leak; a table nothing deletes from is a bug waiting for a |
| 93 | // big enough database. Refusing a sign-in because the housekeeping |
| 94 | // failed would trade a hygiene problem for an outage. |
| 95 | if err := s.sweep(ctx, time.Now()); err != nil { |
| 96 | s.logger.Warn("sweeping expired sign-in links", "err", err) |
| 97 | } |
| 98 | return nil |
| 99 | } |
| 100 | |
| 101 | // TakeLink consumes a link in one statement. |
| 102 | // |
| 103 | // Exactly one concurrent caller gets a row, so two clicks mint one |
| 104 | // session. Unknown, expired and already-used are all "no row", so a |
| 105 | // handler cannot tell them apart and turn the difference into an oracle. |
| 106 | func (s *linkStore) TakeLink(ctx context.Context, hash, purpose string) (string, bool, error) { |
| 107 | var sealed []byte |
| 108 | err := s.db.Writer().QueryRowContext(ctx, |
| 109 | `DELETE FROM signin_links |
| 110 | WHERE token_hash = ? AND purpose = ? AND expires_at > ? |
| 111 | RETURNING sealed_address`, |
| 112 | hash, purpose, time.Now().UTC().Format(linkTimeFormat)).Scan(&sealed) |
| 113 | if errors.Is(err, sql.ErrNoRows) { |
| 114 | return "", false, nil |
| 115 | } |
| 116 | if err != nil { |
| 117 | return "", false, err |
| 118 | } |
| 119 | |
| 120 | address, err := s.sealer.Open(sealPurposeAddress, sealed) |
| 121 | if err != nil { |
| 122 | return "", false, err |
| 123 | } |
| 124 | return string(address), true, nil |
| 125 | } |
| 126 | |
| 127 | // sweep deletes links that have expired. |
| 128 | func (s *linkStore) sweep(ctx context.Context, now time.Time) error { |
| 129 | _, err := s.db.Writer().ExecContext(ctx, |
| 130 | `DELETE FROM signin_links WHERE expires_at <= ?`, |
| 131 | now.UTC().Format(linkTimeFormat)) |
| 132 | return err |
| 133 | } |
| 134 | |
| 135 | // findOrMintPerson returns the person ref for an address, minting one on |
| 136 | // first sight. |
| 137 | // |
| 138 | // One statement, so a returning person and a first-time person take the |
| 139 | // same path. Without the upsert, a UNIQUE blind index and a fresh ref on |
| 140 | // every visit would attach each session, credential and recovery code to |
| 141 | // an identity nobody can ever return to. |
| 142 | // |
| 143 | // The DO UPDATE looks like a no-op and is not: SQLite's RETURNING yields |
| 144 | // no row for a conflict that does nothing, so writing the index back to |
| 145 | // itself is what makes the existing ref come out. |
| 146 | func (a *App) findOrMintPerson(ctx context.Context, address string) (string, error) { |
| 147 | return findOrMintPersonOn(ctx, a.db.Writer(), a.secrets.Pepper, address, time.Now()) |
| 148 | } |
| 149 | |
| 150 | type personQueryer interface { |
| 151 | QueryRowContext(context.Context, string, ...any) *sql.Row |
| 152 | } |
| 153 | |
| 154 | // The operator path supplies its locked connection so person creation and the |
| 155 | // owner receipt either commit together or both roll back. |
| 156 | func findOrMintPersonOn(ctx context.Context, q personQueryer, pepper []byte, address string, now time.Time) (string, error) { |
| 157 | ref, err := NewPersonRef() |
| 158 | if err != nil { |
| 159 | return "", err |
| 160 | } |
| 161 | var got string |
| 162 | err = q.QueryRowContext(ctx, |
| 163 | `INSERT INTO people (person_ref, blind_index, created_at) |
| 164 | VALUES (?, ?, ?) |
| 165 | ON CONFLICT(blind_index) DO UPDATE SET blind_index = excluded.blind_index |
| 166 | RETURNING person_ref`, |
| 167 | ref, blindIndex(pepper, address), now.UTC().Format(dayFormat)). |
| 168 | Scan(&got) |
| 169 | if err != nil { |
| 170 | return "", err |
| 171 | } |
| 172 | return got, nil |
| 173 | } |
| 174 | |