oficina / home Public

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

Plain git — no account needed to clone.

Download

Download this file

1package home
2
3import (
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.
25const 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.
33const 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.
42const 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.
59func 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.
67type 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.
78func (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.
106func (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.
128func (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.
146func (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
150type 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.
156func 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