oficina / home Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package linkstore binds an existing local person to an independently verified
2// Home identity. It creates no people, sessions, membership or document grants.
3package linkstore
4
5import (
6 "context"
7 "crypto/hmac"
8 "crypto/rand"
9 "crypto/sha256"
10 "database/sql"
11 "embed"
12 "encoding/base64"
13 "encoding/binary"
14 "encoding/hex"
15 "errors"
16 "net/url"
17 "strings"
18 "time"
19 "unicode/utf8"
20
21 "amadan.net/oficina/home/handoff"
22 "amadan.net/oficina/home/handoff/seal"
23)
24
25//go:embed migrations/*.sql
26var migrations embed.FS
27
28// Migrations must be applied before construction.
29var Migrations = migrations
30
31const SchemaNamespace = "oficina_connect_link"
32
33var (
34 ErrRefused = errors.New("home link: refused")
35 ErrStorage = errors.New("home link: storage unavailable")
36)
37
38const maxFlows = 10000
39const maxAge = 300
40
41// Local is supplied only after the caller verifies the actual local session
42// and its method-aware authentication time. Home assertions cannot supply it.
43// SessionToken is the actual cookie value, never a subject or a minted alias.
44type Local struct {
45 PersonID int64
46 SessionToken string
47 AuthTime time.Time
48}
49
50// Home contains already verified, consumed shared-handoff claims. The caller
51// must not construct it from query parameters or an unverified assertion.
52type Home struct {
53 Issuer, Subject, Nonce string
54 AuthTime time.Time
55}
56
57// Pending is safe server-side continuation state, not an authentication token.
58type Pending struct {
59 PersonID int64
60 ReturnPath string
61 HomeAuthTime time.Time
62}
63
64type Store struct {
65 db *sql.DB
66 seal *seal.Sealer
67 origin, issuer string
68 lookup LookupPerson
69 bindingKey []byte
70 namespace string
71}
72
73// New uses a dedicated handoff/storage DEK, never an application content key.
74// Apply Schema first, then call Sweep at boot before accepting requests.
75func New(db *sql.DB, dek []byte, origin, issuer, namespace string, lookup LookupPerson) (*Store, error) {
76 if lookup == nil || db == nil || !validOrigin(origin) || !validOrigin(issuer) {
77 return nil, ErrRefused
78 }
79 sealer, err := seal.New(dek, namespace)
80 if err != nil {
81 return nil, ErrRefused
82 }
83 return &Store{db: db, seal: sealer, origin: origin, issuer: issuer, lookup: lookup, bindingKey: append([]byte(nil), dek...), namespace: namespace}, nil
84}
85
86func validOrigin(v string) bool {
87 u, err := url.Parse(v)
88 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 ")
89}
90
91func hash(v string) string { sum := sha256.Sum256([]byte(v)); return hex.EncodeToString(sum[:]) }
92func secretHash(v string) (string, bool) {
93 if len(v) != 43 {
94 return "", false
95 }
96 b, err := base64.RawURLEncoding.Strict().DecodeString(v)
97 return hash(v), err == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == v
98}
99func newSecret() (string, error) {
100 b := make([]byte, 32)
101 if _, err := rand.Read(b); err != nil {
102 return "", ErrStorage
103 }
104 return base64.RawURLEncoding.EncodeToString(b), nil
105}
106func fresh(at int64, now, high int64) bool { return at > 0 && at <= now && high-at <= maxAge }
107func validLocal(local Local, now, high int64) bool {
108 return local.PersonID > 0 && local.SessionToken != "" && len(local.SessionToken) <= 4096 && fresh(local.AuthTime.Unix(), now, high)
109}
110func validSubject(v string) bool {
111 if v == "" || len(v) > 512 || !utf8.ValidString(v) {
112 return false
113 }
114 for _, r := range v {
115 if r <= 32 || r == 127 {
116 return false
117 }
118 }
119 return true
120}
121
122// begin obtains the write lock before any reads, including capacity checks.
123// A persisted clock prevents stale evidence becoming fresh after a restart or
124// a clock rollback. Match the shared handoff's 30-second rollback tolerance.
125func (s *Store) begin(ctx context.Context, now time.Time) (*sql.Tx, int64, error) {
126 if now.Unix() <= 0 {
127 return nil, 0, ErrRefused
128 }
129 tx, err := s.db.BeginTx(ctx, nil)
130 if err != nil {
131 return nil, 0, ErrStorage
132 }
133 if _, err = tx.ExecContext(ctx, "UPDATE home_link_clock SET high_water = MAX(high_water, ?) WHERE id = 1", now.Unix()); err != nil {
134 tx.Rollback()
135 return nil, 0, ErrStorage
136 }
137 var high int64
138 if err = tx.QueryRowContext(ctx, "SELECT high_water FROM home_link_clock WHERE id = 1").Scan(&high); err != nil {
139 tx.Rollback()
140 return nil, 0, ErrStorage
141 }
142 if now.Unix() < high-30 {
143 tx.Rollback()
144 return nil, 0, ErrRefused
145 }
146 return tx, high, nil
147}
148func refuse(tx *sql.Tx) error {
149 if err := tx.Commit(); err != nil {
150 return ErrStorage
151 }
152 return ErrRefused
153}
154func commit(tx *sql.Tx) error {
155 if err := tx.Commit(); err != nil {
156 return ErrStorage
157 }
158 return nil
159}
160func sweep(ctx context.Context, tx *sql.Tx, high int64) error {
161 if _, err := tx.ExecContext(ctx, "DELETE FROM home_link_flows WHERE expires_at <= ?", high); err != nil {
162 return ErrStorage
163 }
164 return nil
165}
166func (s *Store) Sweep(ctx context.Context, now time.Time) error {
167 tx, high, err := s.begin(ctx, now)
168 if err != nil {
169 return err
170 }
171 defer tx.Rollback()
172 if err := sweep(ctx, tx, high); err != nil {
173 return err
174 }
175 return commit(tx)
176}
177
178// Resolve reads the current existing person, never EnsurePerson or a cached
179// address. A deleted or unlinked person is refused without creating anything.
180func (s *Store) Resolve(ctx context.Context, issuer, subject string) (int64, string, error) {
181 if issuer != s.issuer || !validSubject(subject) {
182 return 0, "", ErrRefused
183 }
184 var id int64
185 var address, binding string
186 err := s.db.QueryRowContext(ctx, `SELECT person_id, person_binding FROM home_identity_links WHERE issuer = ? AND subject = ?`, issuer, subject).Scan(&id, &binding)
187 if errors.Is(err, sql.ErrNoRows) {
188 return 0, "", ErrRefused
189 }
190 if err != nil {
191 return 0, "", ErrStorage
192 }
193 address, err = s.lookup(ctx, s.db, id)
194 if errors.Is(err, sql.ErrNoRows) {
195 return 0, "", ErrRefused
196 }
197 if err != nil {
198 return 0, "", ErrStorage
199 }
200 if id <= 0 || address == "" || !hmac.Equal([]byte(binding), []byte(s.personBinding(id, address))) {
201 return 0, "", ErrRefused
202 }
203 return id, address, nil
204}
205
206func safePath(v string) string {
207 if handoff.SafePath(v) {
208 return v
209 }
210 return "/"
211}
212
213// Queryer permits lookups inside the same transaction as link writes.
214type Queryer interface {
215 QueryRowContext(context.Context, string, ...any) *sql.Row
216}
217
218// LookupPerson returns the canonical verified local address, never a display name.
219// IDs must never be reused; deletion must remove links in the same transaction. It must never
220// create one, and must use q rather than opening a separate transaction.
221// Missing or currently inadmissible people return sql.ErrNoRows.
222type LookupPerson func(ctx context.Context, q Queryer, id int64) (string, error)
223
224// personBinding prevents a stale numeric link from resolving a different address.
225// Length-prefixed fields keep the namespace, origin, ID and address unambiguous.
226func (s *Store) personBinding(id int64, address string) string {
227 m := hmac.New(sha256.New, s.bindingKey)
228 for _, field := range []string{"home-connect/person-binding/v1", s.namespace, s.origin, s.issuer} {
229 _ = binary.Write(m, binary.BigEndian, uint64(len(field)))
230 _, _ = m.Write([]byte(field))
231 }
232 _ = binary.Write(m, binary.BigEndian, id)
233 _ = binary.Write(m, binary.BigEndian, uint64(len(address)))
234 _, _ = m.Write([]byte(address))
235 return hex.EncodeToString(m.Sum(nil))
236}
237