| 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. |
| 16 | package seal |
| 17 | |
| 18 | import ( |
| 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. |
| 33 | const ( |
| 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 | |
| 111 | func sha256New() hash.Hash { return sha256.New() } |
| 112 | |
| 113 | // Sealer holds the data encryption key. One per process. |
| 114 | type Sealer struct{ dek []byte } |
| 115 | |
| 116 | // New returns a Sealer. The DEK must be exactly 32 bytes. |
| 117 | func 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. |
| 133 | func (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 | |
| 142 | func (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. |
| 155 | func (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. |
| 176 | func (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. |
| 187 | func (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 | |