carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

1// Package model is CONTRA's sealed tables, and nothing else.
2//
3// It is a package of its own so that the app, the store and the auth
4// ceremony can all name a row without importing one another — but the
5// separation earns its keep for a second reason: every struct in here is the
6// complete answer to "what does the instance keep?", in one file, with
7// nothing else in the way of reading it.
8//
9// # The rule these tables are shaped by
10//
11// CONTRA is CARLOS A1 strict, and the whole SQLite file is continuously
12// replicated to object storage — so a plaintext column is not a bug caught in
13// review, it is a plaintext leak, in a bucket, forever. Item names, URLs,
14// usernames, folder structure and vault names seal client-side exactly like
15// passwords do, because a name is a secret too.
16//
17// What that means for anyone adding a column: the server stores ciphertext,
18// public keys, opaque random ids, and the parameters it needs to hand the
19// right blob back. Nothing else. There is no column for a searchable title,
20// no column for a display name, and no "just for the admin page" column.
21//
22// **A new storage surface lands with its canary or it does not land.** The
23// greps in internal/contratest/greps_test.go push canary strings through
24// every write path and search the raw database bytes; a new table belongs in
25// that test's write list on the same commit that adds it.
26//
27// Every id is a random UUID, never a content hash: a content-derived
28// identifier at rest is both an A1 violation and an equality oracle.
29package model
30
31import "time"
32
33// Account is an identity on this instance.
34//
35// There is no address column and no provider user-id. EmailIndex is Kass's
36// blind index — an HMAC under a server-held pepper — which proves identity
37// for sign-in and can never decrypt anything (AGENTS.md decision 3). The
38// pepper belongs off the instance, at the routing layer or in a KMS, so that
39// a database-plus-bucket leak alone cannot unblind addresses.
40type Account struct {
41 // ID is 16 random bytes. Not derived from the email, not derived from
42 // anything: an id you can recompute from content is an equality oracle.
43 ID []byte `gorm:"primaryKey;type:blob"`
44
45 // EmailIndex is HMAC-SHA-256(pepper, normalized_email), 32 bytes.
46 EmailIndex []byte `gorm:"uniqueIndex;type:blob;not null"`
47
48 // KDFSalt is the 16 random bytes the passphrase branch stretches
49 // against, served pre-auth. The salt endpoint answers an unknown email
50 // with a deterministic fake so account existence does not leak from it.
51 KDFSalt []byte `gorm:"type:blob;not null"`
52
53 // PublicKey is the account's P-256 public half — what another member
54 // seals a shared vault keyset to. The private half lives inside the
55 // account keyset below and never reaches the server.
56 PublicKey []byte `gorm:"type:blob"`
57
58 // The account keyset — d_acct and K_meta — sealed under RK, with AAD
59 // (account_id, "account.keyset", account_id, version). It is the layer
60 // between the root key and everything a vault needs, so that rotating a
61 // credential does not mean re-encrypting every item.
62 KeysetNonce []byte `gorm:"type:blob"`
63 KeysetCiphertext []byte `gorm:"type:blob"`
64
65 CreatedAt time.Time
66}
67
68// LoginKey is a public key the server authenticates a signed challenge
69// against — the whole of what it stores about a credential.
70//
71// Not a password verifier, hashed or otherwise. A fully compromised server
72// holds something that cannot decrypt, cannot be replayed to impersonate,
73// and adds no offline attack surface it did not already have via the wraps.
74type LoginKey struct {
75 ID []byte `gorm:"primaryKey;type:blob"`
76 AccountID []byte `gorm:"index;type:blob;not null"`
77
78 // Branch is "pw" or "rc" — which peer credential this key belongs to.
79 // A label of ours, not derived from anything the user typed.
80 Branch string `gorm:"not null"`
81
82 // PublicKey is the SEC1 uncompressed P-256 point, 65 bytes.
83 PublicKey []byte `gorm:"type:blob;not null"`
84
85 CreatedAt time.Time
86}
87
88// Wrap is one of the three peer wraps of the root key: passphrase, recovery
89// code, passkey PRF. Peers, not a chain — each is independently sufficient
90// to reach RK and none can be derived from another.
91//
92// PROTOCOL.md requires every wrap row to carry a format version, the KDF
93// parameters (authenticated as AAD), a 16-byte KCV, a 96-bit nonce and the
94// ciphertext. All six are here, and the KCV is load-bearing twice over:
95// AES-GCM is not key-committing, so it is the commitment check, and it is
96// what tells "wrong key" from "corrupted blob" — the distinction that lets a
97// passkey provider returning a mismatched PRF be read as "fast unlock
98// unavailable" rather than an authentication failure.
99type Wrap struct {
100 ID []byte `gorm:"primaryKey;type:blob"`
101 AccountID []byte `gorm:"index;type:blob;not null"`
102
103 // Kind is the AAD's object type: "wrap.pw", "wrap.rc" or "wrap.prf".
104 Kind string `gorm:"index;not null"`
105
106 // FormatVersion and Version are the wrap header. Version is bound into
107 // the AAD, so a malicious server cannot roll a row back to an older
108 // ciphertext and have it open.
109 FormatVersion int `gorm:"not null"`
110 Version uint64 `gorm:"not null"`
111
112 // KDFParams is the canonical JSON of the parameters this wrap was made
113 // under, authenticated as AAD. Stored so parameters can be raised later
114 // with a transparent re-wrap on the next successful unlock — and so the
115 // client can refuse a set below its floor.
116 KDFParams string `gorm:"not null"`
117
118 // CredSalt is the passkey credential's 32 non-secret random bytes,
119 // empty on the other two branches. Several PRF wraps per credential are
120 // harmless: each is an independent ciphertext of the same RK, which is
121 // what lets a newly observed provider output be enrolled as an
122 // additional wrap instead of stranding anyone.
123 CredSalt []byte `gorm:"type:blob"`
124
125 // CredentialID is the passkey this wrap belongs to, empty on the other two
126 // branches. It lives here rather than in a credentials table because a
127 // passkey, to this product, IS a wrap of RK — there is nothing else to
128 // know about one. Several rows per credential are expected and harmless:
129 // a provider that returns a different PRF for the same credential gets
130 // another wrap rather than an error, which is how PROTOCOL.md says to
131 // tolerate the 1Password-class mismatch.
132 CredentialID []byte `gorm:"index;type:blob"`
133
134 KCV []byte `gorm:"type:blob;not null"` // 16 bytes
135 Nonce []byte `gorm:"type:blob;not null"` // 12 bytes
136 Ciphertext []byte `gorm:"type:blob;not null"` // RK, sealed, tag appended
137
138 CreatedAt time.Time
139}
140
141// Vault is a container of items. Its name is not here: a vault name is
142// metadata, metadata is a secret, and it seals into the vault keyset the
143// members hold. The server knows a vault exists and who it serves bytes to.
144type Vault struct {
145 ID []byte `gorm:"primaryKey;type:blob"`
146
147 // AccountID is the owner. A shared vault lives on its owner's instance;
148 // other members are identities on that instance (AGENTS.md decision 1).
149 AccountID []byte `gorm:"index;type:blob;not null"`
150
151 // DropBoxPublicKey is vP_j, the SEC1 uncompressed P-256 point anything
152 // can seal to without unlocking — how a daemon captures an item with no
153 // key in memory. The private half never leaves the members.
154 DropBoxPublicKey []byte `gorm:"type:blob;not null"`
155
156 // The vault keyset — K_vault and the drop-box private half — sealed for
157 // the owner, AAD (account_id, "vault.keyset", vault_id, version).
158 //
159 // Sealed directly under RK, which is the right shape for a vault with one
160 // member and the wrong shape for a shared one. Sharing wraps the same
161 // keyset to each member's account public key by ephemeral-static ECDH,
162 // into the vault_members rows that already exist for it — that is web/8
163 // (discussion 14), and it will decide whether the owner's copy moves
164 // there too. Recorded here so the next person meets a decision rather
165 // than an inconsistency.
166 KeysetNonce []byte `gorm:"type:blob"`
167 KeysetCiphertext []byte `gorm:"type:blob"`
168
169 // Epoch counts vault rotations, and is bound into every item's AAD.
170 //
171 // Removing a member rotates the vault — in a seal-to-public-key model
172 // revocation IS rotation, because the removed member may have cached
173 // K_vault. Rotation is eager: every item re-sealed under the new key and
174 // every member wrap reissued, in ONE transaction guarded by a
175 // compare-and-swap on this number.
176 //
177 // That is what the epoch buys. Without it a writer who began an edit
178 // before the rotation landed would write an item sealed under the key
179 // that was just revoked, and nothing would notice; with it, that writer's
180 // AAD names the old epoch and the write cannot open. It is also why
181 // rotation is eager rather than lazy: lazy needs a per-row
182 // key-generation column, and that column tells the operator which items
183 // changed since a removal.
184 //
185 // Starts at 1. PROTOCOL.md, "Rotation — and what does not heal".
186 Epoch uint64 `gorm:"not null;default:1"`
187
188 CreatedAt time.Time
189}
190
191// VaultMember is the vault keyset sealed to one member's account public key:
192// ephemeral-static ECDH over P-256, HKDF, AES-GCM.
193//
194// These rows are the sharing graph, and the sharing graph is at-rest
195// metadata that A1 tolerates — wrap rows reveal who shares with whom. That
196// is published in the model rather than hidden, and hiding it (per-vault
197// pseudonymous member slots) is a choice we have looked at and not made.
198//
199// Removing a member rotates the vault, because in a seal-to-public-key model
200// revocation *is* rotation: the removed member may have cached the key.
201// Nothing already downloaded is un-downloaded, and no copy pretends
202// otherwise.
203type VaultMember struct {
204 ID []byte `gorm:"primaryKey;type:blob"`
205 VaultID []byte `gorm:"index;type:blob;not null"`
206 AccountID []byte `gorm:"index;type:blob;not null"`
207
208 // EphemeralPublicKey is the sender's throwaway half of the ECDH.
209 EphemeralPublicKey []byte `gorm:"type:blob;not null"`
210
211 Nonce []byte `gorm:"type:blob;not null"`
212 Ciphertext []byte `gorm:"type:blob;not null"`
213
214 CreatedAt time.Time
215}
216
217// Item is one entry in a vault, and it is a single opaque blob on purpose.
218//
219// Title, URL, username, notes, TOTP seed and password are all inside the one
220// ciphertext. Splitting them into columns — even sealed ones — would publish
221// which fields an item has and how long each is, and would invite exactly
222// the plaintext "just the title, for the list" column this whole file exists
223// to refuse. The client seals the item; the list view is built from items
224// the client has already opened.
225//
226// Ciphertext lengths are quantised into 256-byte buckets before sealing, so
227// the bucket cannot be read for item-length fingerprints. Store.PutItem
228// enforces it rather than trusting the caller.
229type Item struct {
230 ID []byte `gorm:"primaryKey;type:blob"`
231 VaultID []byte `gorm:"index;type:blob;not null"`
232
233 // Version is bound into the AAD along with the vault and item ids, so a
234 // ciphertext cannot be transplanted between slots or rolled back.
235 Version uint64 `gorm:"not null"`
236
237 Nonce []byte `gorm:"type:blob;not null"`
238 Ciphertext []byte `gorm:"type:blob;not null"`
239
240 CreatedAt time.Time
241 UpdatedAt time.Time
242}
243
244// LoginChallenge is live state, not history: the bytes a client is proving
245// possession of, between the two requests of one sign-in ceremony.
246//
247// It holds nothing derived from content — a random nonce, an opaque account
248// id, and a branch label of ours. Rows are deleted the moment they are spent
249// and swept when they expire, deliberately: a challenge table that accumulated
250// would become a log of when each account signed in, which is transport-shape
251// residue we do not have to keep and therefore should not.
252type LoginChallenge struct {
253 ID []byte `gorm:"primaryKey;type:blob"`
254 AccountID []byte `gorm:"index;type:blob;not null"`
255
256 // Branch is "pw" or "rc". Bound into the signed message, so a challenge
257 // issued for the recovery credential cannot be spent on the passphrase one.
258 Branch string `gorm:"not null"`
259
260 Challenge []byte `gorm:"index;type:blob;not null"`
261 ExpiresAt time.Time `gorm:"index;not null"`
262 CreatedAt time.Time
263}
264
265// SignupSession is an address that has proved itself and an account that does
266// not exist yet — the gap between "we emailed you a link" and "you chose a
267// passphrase".
268//
269// It holds the blind index, never the address: the email is used to send one
270// message and is not kept. TokenHash is SHA-256 of the emailed token, so a
271// database-plus-bucket leak yields nothing anyone can click — the token itself
272// exists only in that message and in the link the person follows.
273//
274// Rows are deleted the moment they are spent and swept when they expire. A
275// table of these that accumulated would become a log of who tried to sign up
276// and when, which is transport-shape residue we do not have to keep.
277type SignupSession struct {
278 ID []byte `gorm:"primaryKey;type:blob"`
279 EmailIndex []byte `gorm:"index;type:blob;not null"`
280 TokenHash []byte `gorm:"index;type:blob;not null"`
281
282 // Verified is false until the link is followed.
283 Verified bool `gorm:"not null"`
284 ExpiresAt time.Time `gorm:"index;not null"`
285 CreatedAt time.Time
286}
287
288// DeviceLink is one live handoff: a fresh device waiting for an enrolled one
289// to seal it a copy of the Account Key. PROTOCOL.md's rung 1, following
290// slopbox's `link` shape rather than inventing another.
291//
292// The row holds no secret. CodeHash is SHA-256 of the normalised code, so a
293// database-plus-bucket leak yields nothing anyone can spend — the code itself
294// exists only on the two screens the person is looking at. Sealed is an ECIES
295// envelope the server has no key for, and the AAD inside it names
296// DevicePublic, so the server cannot relay one envelope to a second device.
297//
298// AccountID is here, where slopbox's equivalent deliberately has no user id,
299// because in CONTRA both ends are already signed in: a fresh device signs in
300// with the passphrase alone and only then discovers it has no Account Key.
301// Scoping every route to the session's account means an unrelated account
302// cannot see that a link exists, let alone spend one — which is what lets the
303// code be short enough to read aloud.
304//
305// Rows are deleted the moment they are claimed and swept when they expire. A
306// table of these that accumulated would be a log of when each account
307// enrolled a device, and there is no reason to keep one.
308type DeviceLink struct {
309 ID []byte `gorm:"primaryKey;type:blob"`
310 CodeHash []byte `gorm:"uniqueIndex;type:blob;not null"`
311
312 // AccountID scopes the link: both ends must be signed in as this account.
313 AccountID []byte `gorm:"index;type:blob;not null"`
314
315 // DevicePublic is the fresh device's ephemeral P-256 point, SEC1
316 // uncompressed. The matching scalar never leaves that browser tab.
317 DevicePublic []byte `gorm:"type:blob;not null"`
318
319 // There is deliberately no device label, which is where this departs from
320 // slopbox. A label would be a plaintext column in a file that replicates
321 // whole — and worse, it is attacker-chosen text shown to the victim at
322 // the exact moment they are deciding whether to hand over a key. "CONTRA
323 // official device" is a free phishing line. The code the person carried
324 // from one screen to the other is the confirmation; a label adds nothing
325 // to it and takes something away.
326
327 // Sealed is the ECIES envelope, empty until an enrolled device approves.
328 Sealed []byte `gorm:"type:blob"`
329
330 ExpiresAt time.Time `gorm:"index;not null"`
331}
332