oficina / memoria Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package seal is the app's only encryption surface.
2//
3// The one rule: memoria's chunks, titles and index are sealed at rest,
4// and the bucket never holds a readable passage. The DEK arrives as
5// platform config and is never written to the database it decrypts.
6//
7// Purpose keys are derived with HKDF so a value sealed as a chunk body
8// cannot be opened as a document title. That is not paranoia: they are
9// adjacent columns of related rows, and a bug that swapped them would be
10// invisible without this.
11//
12// This package is deliberately a near-copy of Sheets' internal/sheets/seal.
13// Sharing it would mean a module both apps depend on, which is a
14// coordination cost the suite has not paid yet; the construction being
15// identical is what matters, and the vector test pins it.
16package seal
17
18import (
19 "crypto/aes"
20 "crypto/cipher"
21 "crypto/hmac"
22 "crypto/rand"
23 "crypto/sha256"
24 "encoding/hex"
25 "errors"
26 "hash"
27 "io"
28
29 "golang.org/x/crypto/hkdf"
30)
31
32// Purposes. One per kind of thing sealed, so a mix-up cannot decrypt.
33const (
34 PurposeChunk = "chunk.text"
35 PurposeTitle = "document.title"
36 PurposeBody = "document.body"
37 PurposeSnapshot = "index.snapshot"
38 PurposeExternal = "document.external_id"
39 // PurposePath seals a document's ancestry in its source. Its own
40 // purpose rather than reusing PurposeTitle: a path and a title are
41 // different kinds of thing, and the rule here is one purpose per
42 // kind so that a mix-up cannot decrypt.
43 PurposePath = "document.path"
44 // PurposeCredential seals a connector's token, so a disclosure of the
45 // chunk store does not also yield the key to the source those chunks
46 // came from.
47 PurposeCredential = "connector.token"
48 // PurposeCoordinate seals a source coordinate — a Notion page id.
49 //
50 // REVERSIBLE, unlike Fingerprint. That distinction is the whole
51 // reason this purpose exists: the fetch phase has to hand the id back
52 // to the API it came from, and an HMAC cannot be handed back.
53 PurposeCoordinate = "source.coordinate"
54 // PurposeItemID seals a SIBLING's own id for a document — the value
55 // that goes on the wire in an S2 question and an S3 fetch.
56 //
57 // Reversible, for the same reason PurposeCoordinate is, and separate
58 // from it for a reason worth stating: these two are the ids memoria
59 // hands back to somebody else, and they go to *different* somebodies.
60 // One purpose for both would mean a bug that opened a Notion page id
61 // under the sibling path would send a Notion coordinate to Docs and it
62 // would decrypt cleanly on the way out. With two, it does not decrypt
63 // at all. "One purpose per kind of thing sealed" earns its keep here:
64 // the kinds differ by who receives them, not by what they look like.
65 PurposeItemID = "sibling.item_id"
66 // PurposeCursor seals a sibling's S1 cursor.
67 //
68 // Reversible, because the whole use of a cursor is handing it back on
69 // the next poll. Its own purpose rather than PurposeItemID's for the
70 // reason that one is separate from PurposeCoordinate: these are the
71 // values memoria gives back to somebody else, and a cursor sent where
72 // an item id belongs would be a well-formed request about the wrong
73 // thing rather than a request that fails.
74 //
75 // Sealed at all because a cursor is the sibling's own encoding and
76 // memoria does not know what is in it. §4.1 requires it to be treated
77 // as opaque — never parsed, never ordered — and storing plaintext
78 // something whose contents are unknown is how a coordinate turns out
79 // to have been a timestamp, a row id, or a count.
80 PurposeCursor = "sibling.cursor"
81 // PurposeTally seals §8.1's breaker counters — per (sibling, asker,
82 // UTC day), how many passages that person was allowed to see and how
83 // many they were refused.
84 //
85 // **This is the per-person passage-access record**, and §3.2's
86 // leak list carries it rather than leaving it to be found. Two
87 // integers a day is the smallest thing that answers "is this sibling
88 // refusing people it used to allow"; a per-refusal log would answer it
89 // too and is deliberately not built.
90 //
91 // Sealed because the numbers are the sensitive part: "Ana was refused
92 // 40 times by Sheets on Tuesday" is a fact about Ana, inferable from
93 // nothing else in the store, and the bucket must not hold it in the
94 // clear.
95 PurposeTally = "sibling.tally"
96 // PurposeAsker keys the asker in those rows. **One-way**
97 // (Fingerprint), never reversible: the predicate needs only to count
98 // distinct people and compare a person against themselves across two
99 // periods, and it never needs the address back. A reversible id here
100 // would make the tally table a roster of who reads what.
101 PurposeAsker = "sibling.asker"
102 // PurposeReturn seals the page a signed-out visitor was heading for,
103 // carried in a ten-minute cookie from GET /signin?return_to= to where
104 // the magic link lands (shared chrome §9.4). Not content, but a path
105 // can name a document id, and the rule here is one purpose per kind
106 // of thing sealed: a return cookie can never be opened as anything
107 // else, nor anything else as one.
108 PurposeReturn = "signin.return"
109)
110
111func sha256New() hash.Hash { return sha256.New() }
112
113// Sealer holds the data encryption key. One per process.
114type Sealer struct{ dek []byte }
115
116// New returns a Sealer. The DEK must be exactly 32 bytes.
117func New(dek []byte) (*Sealer, error) {
118 if len(dek) != 32 {
119 return nil, errors.New("seal: DEK must be 32 bytes")
120 }
121 c := make([]byte, 32)
122 copy(c, dek)
123 return &Sealer{dek: c}, nil
124}
125
126// purposeKey derives the AEAD key for purpose via HKDF over the DEK. A
127// fixed 32-byte read is always inside HKDF-SHA256's expand limit, so
128// this cannot fail today — but the error is still propagated: this is
129// the app's only encryption surface, and a silently discarded read here
130// would degrade into a short or zero-padded key if the key length ever
131// changed, which is the kind of bug that leaves ciphertext looking
132// perfectly fine while being trivially breakable.
133func (s *Sealer) purposeKey(purpose string) ([]byte, error) {
134 key := make([]byte, 32)
135 r := hkdf.New(sha256New, s.dek, nil, []byte("oficina/memoria:"+purpose))
136 if _, err := io.ReadFull(r, key); err != nil {
137 return nil, err
138 }
139 return key, nil
140}
141
142func (s *Sealer) aeadFor(purpose string) (cipher.AEAD, error) {
143 key, err := s.purposeKey(purpose)
144 if err != nil {
145 return nil, err
146 }
147 block, err := aes.NewCipher(key)
148 if err != nil {
149 return nil, err
150 }
151 return cipher.NewGCM(block)
152}
153
154// Seal encrypts plaintext under purpose. The nonce is prepended.
155func (s *Sealer) Seal(purpose string, plaintext []byte) ([]byte, error) {
156 aead, err := s.aeadFor(purpose)
157 if err != nil {
158 return nil, err
159 }
160 nonce := make([]byte, aead.NonceSize())
161 if _, err := rand.Read(nonce); err != nil {
162 return nil, err
163 }
164 return aead.Seal(nonce, nonce, plaintext, nil), nil
165}
166
167// Fingerprint returns a keyed, deterministic identifier for plaintext.
168//
169// It exists because a bare SHA-256 of a document body is a confirmation
170// oracle: anyone holding the bucket and a candidate document can hash it,
171// find the row, and learn that this team holds that file — plus its size,
172// its timestamps and its chunk count. A content hash is not a coordinate,
173// however much it looks like one, and §3.2's plaintext set does not admit
174// it. HMAC under a purpose key keeps the idempotency and removes the
175// oracle.
176func (s *Sealer) Fingerprint(purpose string, plaintext []byte) (string, error) {
177 key, err := s.purposeKey(purpose)
178 if err != nil {
179 return "", err
180 }
181 m := hmac.New(sha256New, key)
182 m.Write(plaintext)
183 return hex.EncodeToString(m.Sum(nil)), nil
184}
185
186// Open decrypts a value sealed under the same purpose.
187func (s *Sealer) Open(purpose string, ct []byte) ([]byte, error) {
188 aead, err := s.aeadFor(purpose)
189 if err != nil {
190 return nil, err
191 }
192 if len(ct) < aead.NonceSize() {
193 return nil, errors.New("seal: ciphertext too short")
194 }
195 nonce, body := ct[:aead.NonceSize()], ct[aead.NonceSize():]
196 return aead.Open(nil, nonce, body, nil)
197}
198