oficina / home Public

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

Plain git — no account needed to clone.

Download

Download this file

1package linkstore
2
3import (
4 "bytes"
5 "context"
6 "crypto/hmac"
7 "database/sql"
8 "encoding/json"
9 "errors"
10 "io"
11 "time"
12
13 "amadan.net/oficina/home/handoff"
14)
15
16type flow struct {
17 PersonBinding string `json:"person_binding"`
18 Version int `json:"v"`
19 PersonID int64 `json:"person"`
20 SessionHash string `json:"session"`
21 LocalAuth int64 `json:"local_auth"`
22 Issuer string `json:"issuer"`
23 NonceHash string `json:"nonce,omitempty"`
24 Subject string `json:"subject,omitempty"`
25 HomeAuth int64 `json:"home_auth,omitempty"`
26 ReturnPath string `json:"return"`
27 Expires int64 `json:"expires"`
28}
29
30func (s *Store) purpose(stage, id string) string {
31 return "identity-link/v1/" + stage + "/" + id + "/" + s.origin
32}
33func (s *Store) put(ctx context.Context, tx *sql.Tx, stage, id string, f flow) error {
34 raw, err := json.Marshal(f)
35 if err != nil {
36 return ErrStorage
37 }
38 sealed, err := s.seal.Seal(s.purpose(stage, id), raw)
39 if err != nil {
40 return ErrStorage
41 }
42 _, err = tx.ExecContext(ctx, "INSERT INTO home_link_flows (id_hash, stage, expires_at, payload) VALUES (?, ?, ?, ?)", id, stage, f.Expires, sealed)
43 if err != nil {
44 return ErrStorage
45 }
46 return nil
47}
48
49// load deletes a presented row before interpreting it when consume is true.
50// Callers commit refusals, so a rejected conflict or mixed-up browser cannot
51// reuse its proof. Storage failures roll back and never install a mapping.
52func (s *Store) load(ctx context.Context, tx *sql.Tx, stage, id string, high int64, consume bool) (flow, error) {
53 var raw []byte
54 var expires int64
55 err := tx.QueryRowContext(ctx, "SELECT expires_at, payload FROM home_link_flows WHERE id_hash = ? AND stage = ?", id, stage).Scan(&expires, &raw)
56 if errors.Is(err, sql.ErrNoRows) {
57 return flow{}, ErrRefused
58 }
59 if err != nil {
60 return flow{}, ErrStorage
61 }
62 if consume {
63 if _, err := tx.ExecContext(ctx, "DELETE FROM home_link_flows WHERE id_hash = ? AND stage = ?", id, stage); err != nil {
64 return flow{}, ErrStorage
65 }
66 }
67 if len(raw) > 8192 || expires <= high {
68 return flow{}, ErrRefused
69 }
70 opened, err := s.seal.Open(s.purpose(stage, id), raw)
71 if err != nil {
72 return flow{}, ErrRefused
73 }
74 var f flow
75 dec := json.NewDecoder(bytes.NewReader(opened))
76 dec.DisallowUnknownFields()
77 if dec.Decode(&f) != nil || dec.Decode(new(any)) != io.EOF {
78 return flow{}, ErrRefused
79 }
80 if f.Version != 1 || f.Expires != expires || f.Issuer != s.issuer || !handoff.SafePath(f.ReturnPath) {
81 return flow{}, ErrRefused
82 }
83 return f, nil
84}
85func (s *Store) matches(f flow, local Local, now, high int64) bool {
86 return validLocal(local, now, high) && f.PersonID == local.PersonID && f.SessionHash == hash(local.SessionToken) && f.LocalAuth == local.AuthTime.Unix() && fresh(f.LocalAuth, now, high)
87}
88func (s *Store) currentBinding(ctx context.Context, tx *sql.Tx, id int64) (string, error) {
89 label, err := s.lookup(ctx, tx, id)
90 if errors.Is(err, sql.ErrNoRows) {
91 return "", nil
92 }
93 if err != nil {
94 return "", ErrStorage
95 }
96 if label == "" {
97 return "", nil
98 }
99 return s.personBinding(id, label), nil
100}
101
102// Start is called only by an explicit same-origin local-account link POST.
103// The returned secret belongs in a host-only Secure/HttpOnly/Lax cookie.
104func (s *Store) Start(ctx context.Context, local Local, returnPath string, now time.Time) (string, error) {
105 tx, high, err := s.begin(ctx, now)
106 if err != nil {
107 return "", err
108 }
109 defer tx.Rollback()
110 if err = sweep(ctx, tx, high); err != nil {
111 return "", err
112 }
113 if !validLocal(local, now.Unix(), high) {
114 return "", refuse(tx)
115 }
116 binding, err := s.currentBinding(ctx, tx, local.PersonID)
117 if err != nil {
118 return "", err
119 }
120 if binding == "" {
121 return "", refuse(tx)
122 }
123 var count int
124 if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM home_link_flows").Scan(&count); err != nil {
125 return "", ErrStorage
126 }
127 if count >= maxFlows {
128 return "", refuse(tx)
129 }
130 secret, err := newSecret()
131 if err != nil {
132 return "", err
133 }
134 expires := min(now.Unix()+maxAge, local.AuthTime.Unix()+maxAge)
135 if expires <= high {
136 return "", refuse(tx)
137 }
138 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}
139 if err := s.put(ctx, tx, "init", hash(secret), f); err != nil {
140 return "", err
141 }
142 if err := commit(tx); err != nil {
143 return "", err
144 }
145 return secret, nil
146}
147
148// BindNonce is the shared consumer's start hook. It accepts one generated
149// nonce exactly once, before that consumer sets its cookie or redirects.
150func (s *Store) BindNonce(ctx context.Context, secret string, local Local, nonce string, now time.Time) error {
151 id, ok := secretHash(secret)
152 if !ok {
153 return ErrRefused
154 }
155 tx, high, err := s.begin(ctx, now)
156 if err != nil {
157 return err
158 }
159 defer tx.Rollback()
160 f, err := s.load(ctx, tx, "init", id, high, true)
161 if errors.Is(err, ErrRefused) {
162 return refuse(tx)
163 }
164 if err != nil {
165 return err
166 }
167 if !s.matches(f, local, now.Unix(), high) || !handoff.ValidNonce(nonce) || f.NonceHash != "" {
168 return refuse(tx)
169 }
170 binding, err := s.currentBinding(ctx, tx, local.PersonID)
171 if err != nil {
172 return err
173 }
174 if binding == "" || !hmac.Equal([]byte(binding), []byte(f.PersonBinding)) {
175 return refuse(tx)
176 }
177 f.NonceHash = hash(nonce)
178 if err := s.put(ctx, tx, "init", id, f); err != nil {
179 return err
180 }
181 return commit(tx)
182}
183
184// Complete consumes the initiation after the shared consumer has consumed and
185// verified the Home assertion. It keeps the local Docs session intact.
186func (s *Store) Complete(ctx context.Context, secret string, local Local, home Home, now time.Time) (string, error) {
187 id, ok := secretHash(secret)
188 if !ok {
189 return "", ErrRefused
190 }
191 tx, high, err := s.begin(ctx, now)
192 if err != nil {
193 return "", err
194 }
195 defer tx.Rollback()
196 f, err := s.load(ctx, tx, "init", id, high, true)
197 if errors.Is(err, ErrRefused) {
198 return "", refuse(tx)
199 }
200 if err != nil {
201 return "", err
202 }
203 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) {
204 return "", refuse(tx)
205 }
206 binding, err := s.currentBinding(ctx, tx, local.PersonID)
207 if err != nil {
208 return "", err
209 }
210 if binding == "" || !hmac.Equal([]byte(binding), []byte(f.PersonBinding)) {
211 return "", refuse(tx)
212 }
213 pending, err := newSecret()
214 if err != nil {
215 return "", err
216 }
217 f.Subject, f.HomeAuth = home.Subject, home.AuthTime.Unix()
218 f.Expires = min(f.Expires, f.HomeAuth+maxAge)
219 if f.Expires <= high {
220 return "", refuse(tx)
221 }
222 if err := s.put(ctx, tx, "pending", hash(pending), f); err != nil {
223 return "", err
224 }
225 if err := commit(tx); err != nil {
226 return "", err
227 }
228 return pending, nil
229}
230
231func validPending(f flow, now, high int64) bool {
232 return f.NonceHash != "" && validSubject(f.Subject) && fresh(f.HomeAuth, now, high)
233}
234func pendingView(f flow) Pending {
235 return Pending{PersonID: f.PersonID, ReturnPath: f.ReturnPath, HomeAuthTime: time.Unix(f.HomeAuth, 0)}
236}
237
238// Pending reads a still-valid continuation for its current local session.
239// Invalid presented state is consumed; reading valid state creates no link.
240func (s *Store) Pending(ctx context.Context, secret string, local Local, now time.Time) (Pending, error) {
241 id, ok := secretHash(secret)
242 if !ok {
243 return Pending{}, ErrRefused
244 }
245 tx, high, err := s.begin(ctx, now)
246 if err != nil {
247 return Pending{}, err
248 }
249 defer tx.Rollback()
250 f, err := s.load(ctx, tx, "pending", id, high, true)
251 if errors.Is(err, ErrRefused) {
252 return Pending{}, refuse(tx)
253 }
254 if err != nil {
255 return Pending{}, err
256 }
257 if !s.matches(f, local, now.Unix(), high) || !validPending(f, now.Unix(), high) {
258 return Pending{}, refuse(tx)
259 }
260 binding, err := s.currentBinding(ctx, tx, local.PersonID)
261 if err != nil {
262 return Pending{}, err
263 }
264 if binding == "" || !hmac.Equal([]byte(binding), []byte(f.PersonBinding)) {
265 return Pending{}, refuse(tx)
266 }
267 if err := s.put(ctx, tx, "pending", id, f); err != nil {
268 return Pending{}, err
269 }
270 if err := commit(tx); err != nil {
271 return Pending{}, err
272 }
273 return pendingView(f), nil
274}
275
276// Confirm requires an explicit same-origin POST. The proof deletion and unique
277// mapping write commit together. A separate proof for the same mapping is
278// idempotent; a reused cookie or conflicting mapping is consumed and refused.
279func (s *Store) Confirm(ctx context.Context, secret string, local Local, now time.Time) (Pending, error) {
280 id, ok := secretHash(secret)
281 if !ok {
282 return Pending{}, ErrRefused
283 }
284 tx, high, err := s.begin(ctx, now)
285 if err != nil {
286 return Pending{}, err
287 }
288 defer tx.Rollback()
289 f, err := s.load(ctx, tx, "pending", id, high, true)
290 if errors.Is(err, ErrRefused) {
291 return Pending{}, refuse(tx)
292 }
293 if err != nil {
294 return Pending{}, err
295 }
296 if !s.matches(f, local, now.Unix(), high) || !validPending(f, now.Unix(), high) {
297 return Pending{}, refuse(tx)
298 }
299 binding, err := s.currentBinding(ctx, tx, local.PersonID)
300 if err != nil {
301 return Pending{}, err
302 }
303 if binding == "" || !hmac.Equal([]byte(binding), []byte(f.PersonBinding)) {
304 return Pending{}, refuse(tx)
305 }
306 _, 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)
307 if err != nil {
308 return Pending{}, ErrStorage
309 }
310 var person int64
311 var storedBinding string
312 err = tx.QueryRowContext(ctx, "SELECT person_id, person_binding FROM home_identity_links WHERE issuer = ? AND subject = ?", s.issuer, f.Subject).Scan(&person, &storedBinding)
313 if errors.Is(err, sql.ErrNoRows) {
314 return Pending{}, refuse(tx)
315 }
316 if err != nil {
317 return Pending{}, ErrStorage
318 }
319 if person != f.PersonID || !hmac.Equal([]byte(storedBinding), []byte(f.PersonBinding)) {
320 return Pending{}, refuse(tx)
321 }
322 if err := commit(tx); err != nil {
323 return Pending{}, err
324 }
325 return pendingView(f), nil
326}
327