oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package linkstore binds an existing Docs 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/rand"
8 "crypto/sha256"
9 "database/sql"
10 "embed"
11 "encoding/base64"
12 "encoding/hex"
13 "errors"
14 "net/url"
15 "strings"
16 "time"
17 "unicode/utf8"
18
19 "amadan.net/oficina/home/handoff"
20 "amadan.net/oficina/home/handoff/seal"
21 "amadan.net/rastrillo/rastrillo/migrate"
22)
23
24//go:embed migrations/*.sql
25var migrations embed.FS
26
27// Schema follows the Docs people schema in the application's BootSchema.
28var Schema = migrate.MustFromFS(migrations, "docs_identity_link")
29
30var (
31 ErrRefused = errors.New("docs link: refused")
32 ErrStorage = errors.New("docs link: storage unavailable")
33)
34
35const maxFlows = 10000
36const maxAge = 300
37
38// Local is supplied only after the caller verifies the actual local session
39// and its method-aware authentication time. Home assertions cannot supply it.
40// SessionToken is the actual cookie value, never a subject or a minted alias.
41type Local struct {
42 PersonID int64
43 SessionToken string
44 AuthTime time.Time
45}
46
47// Home contains already verified, consumed shared-handoff claims. The caller
48// must not construct it from query parameters or an unverified assertion.
49type Home struct {
50 Issuer, Subject, Nonce string
51 AuthTime time.Time
52}
53
54// Pending is safe server-side continuation state, not an authentication token.
55type Pending struct {
56 PersonID int64
57 ReturnPath string
58 HomeAuthTime time.Time
59}
60
61type Store struct {
62 db *sql.DB
63 seal *seal.Sealer
64 origin, issuer string
65}
66
67// New uses a dedicated handoff/storage DEK, never the document content key.
68// Apply Schema first, then call Sweep at boot before accepting requests.
69func New(db *sql.DB, dek []byte, origin, issuer string) (*Store, error) {
70 if db == nil || !validOrigin(origin) || !validOrigin(issuer) {
71 return nil, ErrRefused
72 }
73 sealer, err := seal.New(dek, "oficina/docs")
74 if err != nil {
75 return nil, ErrRefused
76 }
77 return &Store{db: db, seal: sealer, origin: origin, issuer: issuer}, nil
78}
79
80func validOrigin(v string) bool {
81 u, err := url.Parse(v)
82 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 ")
83}
84
85func hash(v string) string { sum := sha256.Sum256([]byte(v)); return hex.EncodeToString(sum[:]) }
86func secretHash(v string) (string, bool) {
87 if len(v) != 43 {
88 return "", false
89 }
90 b, err := base64.RawURLEncoding.Strict().DecodeString(v)
91 return hash(v), err == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == v
92}
93func newSecret() (string, error) {
94 b := make([]byte, 32)
95 if _, err := rand.Read(b); err != nil {
96 return "", ErrStorage
97 }
98 return base64.RawURLEncoding.EncodeToString(b), nil
99}
100func fresh(at int64, now, high int64) bool { return at > 0 && at <= now && high-at <= maxAge }
101func validLocal(local Local, now, high int64) bool {
102 return local.PersonID > 0 && local.SessionToken != "" && len(local.SessionToken) <= 4096 && fresh(local.AuthTime.Unix(), now, high)
103}
104func validSubject(v string) bool {
105 if v == "" || len(v) > 512 || !utf8.ValidString(v) {
106 return false
107 }
108 for _, r := range v {
109 if r <= 32 || r == 127 {
110 return false
111 }
112 }
113 return true
114}
115
116// begin obtains the write lock before any reads, including capacity checks.
117// A persisted clock prevents stale evidence becoming fresh after a restart or
118// a clock rollback. Match the shared handoff's 30-second rollback tolerance.
119func (s *Store) begin(ctx context.Context, now time.Time) (*sql.Tx, int64, error) {
120 if now.Unix() <= 0 {
121 return nil, 0, ErrRefused
122 }
123 tx, err := s.db.BeginTx(ctx, nil)
124 if err != nil {
125 return nil, 0, ErrStorage
126 }
127 if _, err = tx.ExecContext(ctx, "UPDATE docs_link_clock SET high_water = MAX(high_water, ?) WHERE id = 1", now.Unix()); err != nil {
128 tx.Rollback()
129 return nil, 0, ErrStorage
130 }
131 var high int64
132 if err = tx.QueryRowContext(ctx, "SELECT high_water FROM docs_link_clock WHERE id = 1").Scan(&high); err != nil {
133 tx.Rollback()
134 return nil, 0, ErrStorage
135 }
136 if now.Unix() < high-30 {
137 tx.Rollback()
138 return nil, 0, ErrRefused
139 }
140 return tx, high, nil
141}
142func refuse(tx *sql.Tx) error {
143 if err := tx.Commit(); err != nil {
144 return ErrStorage
145 }
146 return ErrRefused
147}
148func commit(tx *sql.Tx) error {
149 if err := tx.Commit(); err != nil {
150 return ErrStorage
151 }
152 return nil
153}
154func sweep(ctx context.Context, tx *sql.Tx, high int64) error {
155 if _, err := tx.ExecContext(ctx, "DELETE FROM docs_link_flows WHERE expires_at <= ?", high); err != nil {
156 return ErrStorage
157 }
158 return nil
159}
160func (s *Store) Sweep(ctx context.Context, now time.Time) error {
161 tx, high, err := s.begin(ctx, now)
162 if err != nil {
163 return err
164 }
165 defer tx.Rollback()
166 if err := sweep(ctx, tx, high); err != nil {
167 return err
168 }
169 return commit(tx)
170}
171
172// Resolve reads the current existing person, never EnsurePerson or a cached
173// address. A deleted or unlinked person is refused without creating anything.
174func (s *Store) Resolve(ctx context.Context, issuer, subject string) (int64, string, error) {
175 if issuer != s.issuer || !validSubject(subject) {
176 return 0, "", ErrRefused
177 }
178 var id int64
179 var address string
180 err := s.db.QueryRowContext(ctx, `SELECT p.id, p.email FROM docs_identity_links l JOIN people p ON p.id = l.person_id WHERE l.issuer = ? AND l.subject = ?`, issuer, subject).Scan(&id, &address)
181 if errors.Is(err, sql.ErrNoRows) {
182 return 0, "", ErrRefused
183 }
184 if err != nil {
185 return 0, "", ErrStorage
186 }
187 if id <= 0 || address == "" {
188 return 0, "", ErrRefused
189 }
190 return id, address, nil
191}
192
193func safePath(v string) string {
194 if handoff.SafePath(v) {
195 return v
196 }
197 return "/"
198}
199