carlos / contra Public

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

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– PROTOCOL β€” CONTRA's cryptographic protocol and key hierarchy
2
3Renamed from `DESIGN.md` on 2026-09-04. It was never a design document in
4the visual sense, and the name was in the way: `DESIGN.md` is the
5conventional home for a project's visual system, and this file is the wire
6format. They are now separate files with separate jobs.
7
8Status: **approved design β€” not yet built.** Reviewed 2026-09-01; the
9Account Key decision settled the same day. Everything here is binding: if
10a decision proves wrong in practice, stop and flag it rather than
11redesigning in place.
12
13`AGENTS.md` is the one rule and the settled decisions. This is the spec.
14
15## The shape
16
17One root key, three peer wraps. The wraps are **peers, not a chain** β€”
18each is independently sufficient to reach the root, and none can be
19derived from another.
20
21```
22passphrase ──Argon2id(m=64MiB, t=3, p=1, salt_pw)──► S_pw (32B)
23 S_pw ──HKDF-SHA-256, info="contra/v1/login"──► seed ──► P-256 keypair LK_pw
24 (server stores the PUBLIC key only)
25 S_pw ──HKDF-SHA-256, salt=account_key, info="contra/v1/kek"──► KEK_pw
26 W_pw = AES-256-GCM(KEK_pw, RK)
27
28account key (128-bit random, printed once on the kit, cached per device)
29 mixes into KEK_pw above β€” so W_pw is not an offline target without it
30
31recovery code (128-bit random, printed once)
32 ──HKDF, info="contra/v1/rc-login"──► P-256 keypair LK_rc (public key to server)
33 ──HKDF, info="contra/v1/rc-kek"────► KEK_rc W_rc = AES-GCM(KEK_rc, RK)
34
35passkey PRF output (32B, UV required)
36 ──HKDF, salt=cred_salt, info="contra/v1/prf-kek"──► KEK_prf[i]
37 W_prf[i] = AES-GCM(KEK_prf[i], RK)
38
39RK 256-bit random, generated client-side at signup β€” the root
40 ──AES-GCM──► account keyset { d_acct : P-256 private key
41 K_meta : AES-256 key for account metadata }
42
43vault j keyset { K_vault : AES-256 bulk key
44 (vd_j, vP_j) : P-256 drop-box keypair }
45 wrapped to each member: ephemeral-static ECDH(P-256) β†’ HKDF β†’ AES-GCM,
46 sealed to that member's account public key
47
48items: AES-256-GCM under K_vault, AAD = (vault_id, item_id, version)
49drop-box writes (no unlock needed): ECIES-seal to vP_j, re-encrypted under
50 K_vault by the next writer who is unlocked
51```
52
53Every wrap row carries a format version, the KDF parameters (authenticated
54as AAD), a 16-byte key-check value `KCV = HMAC-SHA-256(KEK,
55"contra/v1/kcv")[..16]`, a 96-bit random nonce, and the ciphertext.
56
57Every AEAD's AAD binds `(account_id, object_type, object_id, version)` so
58a malicious server cannot transplant a ciphertext between slots or between
59users.
60
61**All object identifiers are random UUIDs, never content hashes.** No
62convergent encryption and no dedup: a content-derived identifier at rest
63is both an A1 violation and an equality oracle.
64
65## Sign-in is a signature, not a verifier
66
67One expensive Argon2id call, then HKDF-Expand into two subkeys under
68distinct `info` labels. Independence is provable from HKDF's PRF property:
69holding the login branch says nothing about the KEK branch without
70inverting HMAC-SHA-256.
71
72The client derives a deterministic P-256 signing keypair from the login
73branch and the server stores **only the public key**, authenticating by
74signed challenge. Better than a stored password verifier in three ways: a
75fully compromised server holds something that cannot decrypt, cannot be
76replayed to impersonate, and adds no offline attack surface it did not
77already have via `W_pw`. It is also one auth mechanism shared by the
78browser, the Mac app and the daemon.
79
80**The client pins a hard floor on KDF parameters β€” m β‰₯ 64 MiB, t β‰₯ 3 β€” and
81refuses anything weaker the server offers.** Without the floor a malicious
82server serves `m=8KiB, t=1`, harvests one challenge signature, and cracks
83the passphrase cheaply offline. The floor is the defence, not the
84ceremony.
85
86## Parameters, and what they cost
87
88- **Argon2id m=64 MiB, t=3, p=1**, 32-byte tag. 64 MiB is the largest
89 allocation that reliably succeeds in iOS Safari's WASM heap under memory
90 pressure. `p=1` is forced, not chosen: Argon2id's output depends on `p`,
91 WASM without `SharedArrayBuffer` cannot parallelise, so `p>1` would
92 break byte-identity across Go/JS/Swift. What we traded: multicore
93 speedup on native, and the last factor of 2–4 of memory-hardness versus
94 a native-only product. Parameters are versioned in the wrap header and
95 authenticated as AAD, so they can be raised later with transparent
96 re-wrap on the next successful unlock.
97- **Salt:** 16 random bytes, client-generated at signup and at every
98 passphrase change, served pre-auth. The salt endpoint answers an unknown
99 email with a deterministic fake salt `HMAC(pepper, email)`, so account
100 existence does not leak from it.
101- **HKDF-SHA-256 everywhere**, every derivation carrying an explicit
102 versioned `info` label.
103- Golden vectors cover Argon2id, every HKDF label, Crockford
104 normalisation and the checksum. Cross-platform KDF drift is exactly the
105 bug class behind the 1Password PRF mismatch.
106- **The per-wrap KCV is a key *confirmation* check first.** Its everyday
107 job is distinguishing "wrong key" from "corrupted blob" β€” a distinction
108 the unlock paths need constantly, and the whole of the passkey
109 PRF-mismatch detector below. AES-GCM is also not key-committing, and the
110 KCV supplies that too as a side effect, on exactly the rows that need
111 it. Earlier drafts had this the other way round, which made the
112 commitment sound load-bearing and the confirmation incidental.
113
114 `HMAC-SHA-256(KEK, label)[..16]` **is** `HKDF-Expand(KEK, label)` minus
115 a counter byte β€” that is, the Albertini et al. key-derivation
116 commitment, not a hand-rolled substitute for one.
117
118 Keysets and items carry no KCV and need none: nobody but the client can
119 hold or enumerate RK, `K_vault`, or an ECDH-derived key, so a multi-key
120 ciphertext cannot be built against them. Every key-commitment attack β€”
121 partitioning oracles, invisible salamanders β€” requires the attacker to
122 choose or enumerate the keys the victim opens under, and nobody in the
123 compromise matrix below can. `W_pw` is the only branch with a dictionary
124 at all, it is gated on the Account Key, and the KCV is checked *before*
125 decryption, so a crafted multi-key ciphertext gets past it for at most
126 one key.
127
128 **Committing AEAD and XAES-256-GCM: considered and rejected**
129 (2026-09-02, discussion 22). The property is already present where it is
130 needed; the commitment here is a property of the decryption *procedure*
131 rather than of the bytes, so a second implementation that skips the KCV
132 check loses it β€” that is discipline to enforce, not a risk being
133 carried. If commitment is ever wanted for items, the tool is 32 zero
134 bytes prefixed to the plaintext, not a cipher change. Reopening this
135 wants a new fact; Albertini 2020, Len–Grubbs–Ristenpart 2021 and XAES
136 2024 all predate this document, which already named the non-committing
137 property.
138
139 Nit, recorded and not acted on: the KCV is unsalted, so two `W_prf` rows
140 with equal KEKs have equal KCVs β€” telling the server that two providers
141 return the same PRF, which the random nonces would otherwise hide.
142 Salting with the wrap id removes it and changes the golden vectors.
143
144## The recovery code
145
146**128 bits of CSPRNG entropy, Crockford Base32, 26 data characters plus a
1474-character checksum**, printed as `C1-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX`
148with a QR of the same payload on the sheet.
149
150- 128 bits because it must resist the replicated bucket forever. More buys
151 nothing.
152- Crockford Base32 because it is case-insensitive and decodes O→0 and
153 I/L→1 — the exact confusions humans and OCR make off aged paper.
154- The checksum is 20 bits of `SHA-256(payload)`. On entry the client
155 normalises, checks, and on failure brute-forces every single-character
156 substitution (~30 positions Γ— 32 characters β‰ˆ 10Β³ candidates) against
157 the checksum and then the KCV. Typos self-heal with no exotic
158 error-correcting code. Chosen over BIP39 words: language-neutral, and
159 word lists localise badly.
160- **HKDF, not Argon2id.** The input is already full-entropy, so
161 memory-hardness adds nothing, and the recovery path must be the
162 simplest, least-rotted code in the product. No server-supplied
163 parameters on this path at all β€” immune to downgrade by construction.
164- The crypto cannot tell the owner from a burglar holding the sheet, so
165 **policy must**: the online recovery flow notifies every enrolled device
166 and the account email, with an optional user-configurable delay.
167
168## Passkey PRF fast unlock
169
170A third, additive wrap β€” never a shortcut around RK. Enrolment creates the
171credential then immediately asserts with `prf.eval.first = cred_salt` (32
172random non-secret bytes, stored server-side);
173`KEK_prf = HKDF(prf_output, salt=cred_salt, info="contra/v1/prf-kek")`. One
174assertion both signs the login challenge and returns the PRF output, so
175one touch signs in and unlocks β€” through RK, not around it.
176
177Tolerating the 1Password-class mismatch, where a provider returns a
178different PRF for the same credential:
179
180- Pin `userVerification: "required"` in every ceremony. CTAP2's
181 hmac-secret keeps distinct with-UV and without-UV secrets, and mixed UV
182 policy is a known cause of the mismatch.
183- **Check the KCV before attempting decryption.** A mismatch means "this
184 provider returns a different PRF here" β€” treat it as *fast unlock
185 unavailable*, never as an authentication failure, since the assertion
186 signature is still valid for sign-in. Fall back to the passphrase
187 silently, then offer to enrol the newly observed output as an
188 *additional* wrap. Several `W_prf` rows per credential, keyed by
189 provider fingerprint, are harmless: each is an independent ciphertext of
190 the same RK.
191- PRF wraps are pure convenience. Their loss, mismatch or deletion can
192 never strand anyone, because `W_pw` and `W_rc` are never removed.
193
194The Mac daemon follows the same pattern at vault granularity: its own
195Secure-Enclave-backed P-256 device keypair, with the user granting access
196by wrapping specific vault keysets to it. Least privilege, and captured
197items can still be sealed to `vP_j` with no unlock at all.
198
199## Rotation β€” and what does not heal
200
201- **Routine passphrase change:** new salt, new `S_pw`, new `LK_pw` public
202 key, new `W_pw`, old wrap row deleted. Nothing else moves. This protects
203 against *future* disclosure. It does **not** revoke the old passphrase:
204 an attacker holding an archived snapshot has `W_pw_old` and RK is
205 unchanged.
206- **Credential rotation ("I think it was exposed"):** new RKβ€², new
207 `d_acct`β€², every vault keyset re-wrapped to the new public key (one
208 ECIES seal per vault β€” cheap), all three wrap families reissued, which
209 forces printing a new recovery code. Old snapshots plus old secrets then
210 yield only old vault keysets β€” which still open current items, because
211 `K_vault` has not moved.
212- **Full healing of a vault is vault rotation:** new keyset, items
213 re-encrypted, re-wrapped to members. **Run it automatically on member
214 removal** β€” in a seal-to-public-key model, revocation *is* rotation,
215 because the removed member may have cached `K_vault`.
216
217 **Eager, not lazy, and this is the resolution of a conflict** (2026-09-02,
218 discussion 23). `AGENTS.md` decision 1 says the key "rotates forward for
219 new and changed items", which reads as lazy; this document says items are
220 re-encrypted on removal, which is eager. Eager wins, for a residue
221 reason rather than a performance one: lazy needs a per-row
222 key-generation column, and that column tells the operator which items
223 changed since a member was removed. Re-encrypting N rows is the same
224 bytes as rewriting one blob and the CPU is negligible; what matters is
225 that it is **one transaction**, guarded by a compare-and-swap on the
226 vault epoch, so a concurrent writer gets a conflict rather than writing
227 an item under the key that was just revoked.
228- **Recovery-code regeneration** is a new code, a new `W_rc`, old wrap
229 deleted. "I want a fresh printout" is regeneration; "the paper was
230 exposed" is credential rotation.
231- **Snapshot residue is part of the crypto design, not an ops detail.**
232 Replication plus bucket versioning means every superseded wrap persists.
233 A lifecycle policy expiring old object versions after 30 days bounds the
234 window; without one, every rotation leaves permanent crackable residue.
235 Pin the policy and publish it.
236
237## Compromise matrix
238
239| Compromise | What the attacker gets | What they do not |
240|---|---|---|
241| **Server DB + bucket (passive)** | Every ciphertext and wrap; the sharing graph (wrap rows reveal vault membership and public keys); object sizes and timestamps; the pepper, so email addresses can be confirmed offline | **Nothing.** All three wraps are now keyed by at least 128 bits of full-entropy randomness β€” `W_pw` through the Account Key, `W_rc` and `W_prf` through their own inputs. There is no offline dictionary target left |
242| **Server, actively malicious** | Everything, for any account signing in through the **web console** while compromised β€” because the server serves the JS. This is the honest boundary of A1 for browser clients | Native app and daemon sessions, where the code is signed and pinned |
243| **Signed-in browser (XSS, hostile extension)** | RK and unlocked vault keys in memory: full read and write. Non-extractable WebCrypto keys stop raw-key exfiltration, not plaintext exfiltration | Other accounts; anything after the keys are dropped |
244| **Stolen unlocked laptop** | Everything until auto-lock. After lock the disk is equivalent to a bucket leak | RK after lock, without passphrase, paper, or biometric |
245| **The kit alone (both printed values)** | Full takeover β€” that is its job β€” but only once the online recovery flow releases the blobs, which notifies every device and the account email | Nothing offline. No ciphertexts in hand, no server cooperation, zero |
246| **The Account Key card alone** | Nothing by itself. It is one input to the passphrase branch, useless without the passphrase | RK, the vault, or any wrap. That is *not* a licence to photograph it β€” see "Do not encourage photographing the Account Key card" below, which this row used to contradict |
247| **The passphrase alone** | Sign-in, on a device that already caches the Account Key. On any other device: nothing | The vault, from a fresh device, without the kit or a handoff from an enrolled one |
248
249### At-rest and transport residue, written down (2026-09-02, discussion 23)
250
251The rows above say what an attacker gets from *keys*. These are what the
252stored bytes give up regardless, and they were true before they were
253written here, which is the reason for writing them here.
254
255- **Item count per vault**, from the row count. One blob per vault would
256 hide it; that is rejected for v1 in the same discussion, because
257 drop-box and agent writers cannot produce the next blob and "one blob"
258 becomes "one blob plus an inbox table" whose rows are the most sensitive
259 class of item there is β€” captured, unreviewed secrets.
260- **Per-item write timing and size class.** Reads leak nothing per item:
261 `/api/items` returns the whole vault and there is no per-item GET. That
262 property is deliberate and worth keeping.
263- **Which item a write touched**, in transport.
264- **Sign-in timing per account**, from the framework's `sessions` table,
265 which lives in the same replicated file. The merciless greps scope their
266 DDL check to CONTRA's own tables by design, so this is not a test gap β€”
267 but it is residue and belongs here.
268- **The sharing graph** β€” which accounts share a vault, never what is in
269 it. Already published in the README's trade-offs.
270
271**Rollback is not prevented, and nothing here should be read as claiming
272it is.** The version bound into each item's AAD stops *relabelling*: a
273server cannot serve ciphertext `v3` as `v7`. It does not stop a server
274consistently serving the whole older `(version, nonce, ciphertext)` triple,
275which opens correctly because it is genuinely an older authentic item.
276Detecting that needs client-side memory of the highest version seen per
277item, and for multiple devices a sealed manifest. Post-v1; until it
278exists, no copy may claim rollback protection.
279
280## The Emergency Kit β€” settled (Paul, 2026-09-01)
281
282**Decided: we print the Account Key too, on the same client-generated PDF
283as the recovery code, and layer UX on top of it.** Discussion 5, closed.
284
285### Why
286
287With passphrase-only unlock, `W_pw` sitting in the replicated bucket is an
288offline dictionary target bounded only by ~1 second of Argon2id per guess.
289A human-typical passphrase falls to that, which would have made **bucket
290security equal to passphrase strength** β€” the ceiling on how strong the
291product can ever be, set by the weakest thing a user types.
292
293A 128-bit **Account Key**, mixed into the KEK branch, removes that ceiling
294outright. The bucket becomes uncrackable regardless of how bad the
295passphrase is. This is the half of 1Password's Emergency Kit that actually
296protects the data at rest, and our audience already understands the shape.
297
298**Rejected: Option B, passphrase-only with compensating controls** (a
299pushed generated passphrase, an enforced zxcvbn floor, and a published
300admission that the bucket is only as strong as the passphrase). Rejected
301because a compensating control that depends on user behaviour is not a
302control β€” it is a hope with a progress bar.
303
304### The derivation change
305
306 KEK_pw = HKDF-SHA-256(S_pw, salt=account_key, info="contra/v1/kek")
307
308The account-key input was already specified as present-and-constant so
309this would be a re-wrap rather than a redesign. It now carries the real
310128-bit value. Nothing else in the hierarchy moves: `W_rc` and `W_prf`
311remain peer wraps of the same RK, keyed by their own full-entropy inputs
312and **not** gated on the Account Key.
313
314That last point is deliberate, and the reason changed when the kit became
315two documents. The recovery code alone still reaches RK.
316
317The tempting move is to gate recovery on the Account Key too, now that the
318two live apart β€” defence in depth, and no longer defeated by one
319photograph. **Rejected, because of which document survives what.** The
320Account Key card is the *handy* one: it lives with your devices, because
321that is where it gets used. The Recovery sheet is the *archived* one,
322kept somewhere else precisely so it outlives a disaster at home. A fire, a
323burglary or a lost bag that takes your devices very likely takes the card
324sitting beside them β€” and gating recovery on the card would make the
325archived sheet useless in exactly the scenario it exists for.
326
327So the Recovery sheet must be sufficient on its own. What protects it is
328not a second factor but its handling β€” archival storage, never
329photographed β€” and the notify-every-device-and-delay policy on the online
330recovery flow.
331
332### What the kit is β€” two documents, not two panels
333
334**Two separate PDFs, both generated in the browser at signup, neither ever
335touching the server.** An earlier draft made this one page with a fold
336line; that was too weak, for a reason worth writing down.
337
338The two values have **different blast radii**, and they are not two halves
339of one secret:
340
341- The **Account Key alone is nearly harmless.** It is one input to the
342 passphrase branch and useless without the passphrase. Photographing it
343 costs an attacker nothing.
344- The **recovery code alone is full takeover**, through the online
345 recovery flow. It is a bearer credential for the entire vault.
346
347One is a setup aid; the other is the crown jewel. And the failure we are
348actually guarding against is not printing, it is **storage** β€” people save
349PDFs, and one file means both secrets share a folder forever. A fold line
350does nothing about that. Two files can go to two places, physically and
351digitally.
352
353**Document 1 β€” the Account Key card.** Instruction: **store somewhere
354safe.** Wallet-sized and meant to stay reachable, because a new device
355asks for it β€” but looked after, not left about. It carries the 128-bit key
356as `A1-XXXXX-…` in Crockford Base32, its QR, the account's blind-index
357handle, and one line saying it opens nothing without the passphrase.
358Printing a second copy for another location is fine.
359
360**Document 2 β€” the Recovery sheet.** Instruction: **store securely.**
361Archival, and the higher bar of the two: the 128-bit recovery code as
362`C1-XXXXX-…`, its QR, the same handle, the date, and plain language saying
363this sheet alone can open the vault, that nobody can reissue it, and that
364it should never be photographed.
365
366**The copy must not lean on the adjectives.** "Store securely" and "store
367somewhere safe" are near-synonyms in ordinary English, and a reader will
368not derive two different standards of care from them. The words set the
369register; **concrete places and behaviours do the actual work**, and the
370final copy carries both:
371
372- *Recovery sheet* β€” "Keep it where you'd keep a passport or a will: a
373 safe, a deposit box, a trusted person's house. Never photograph it. You
374 should not need to touch this again."
375- *Account Key card* β€” "Keep it where you can find it and nobody else
376 goes β€” a drawer, a wallet, a folder with your documents. It is useless
377 to anyone who does not also know your passphrase."
378
379**Do not encourage photographing the Account Key card.** An earlier draft
380did, reasoning from its blast radius: alone it opens nothing. But "less
381catastrophic" is not "harmless" β€” it is the second factor protecting the
382at-rest copy, and passphrases get phished. Steering people to put it in a
383cloud photo library would quietly undo the protection the Account Key
384exists to provide. The QR is for scanning at setup, not for archiving to
385a camera roll.
386
387Signup generates both and offers a single **print both** action, so it
388stays one gesture, and does not let the user past until both are confirmed.
389
390### The UX ladder for a fresh device
391
392The Account Key's whole cost is that a new device needs it. Every screen
393that needs one offers all available rungs and **never dead-ends**, cheapest
394first:
395
3961. **Handoff from an enrolled device.** The new browser mints an ephemeral
397 keypair and posts the public half; the server shows a short code; an
398 already-enrolled device confirms it and seals the Account Key to that
399 ephemeral key. The server relays a blob it cannot open β€” hashed code,
400 single use, ten minutes. This is Slopbox's `link` pattern and we reuse
401 its shape rather than inventing one.
4022. **Scan the QR** on the kit with the device's camera.
4033. **Type it**, Crockford Base32 with the self-healing checksum, as the
404 last resort.
405
406Once a device holds the Account Key it is cached locally, and from then on
407**the passphrase alone unlocks that device** β€” which is the everyday case
408and is unchanged from the original promise.
409
410### Reprinting
411
412If either document is lost while at least one device is still enrolled,
413the user must be able to produce a new one, and the flow must be obvious
414rather than buried. The two reprint very differently:
415
416- The **Account Key can be reprinted exactly**, because enrolled devices
417 cache it.
418- The **recovery code cannot** β€” only its wrap is stored, never the value.
419 Reprinting therefore *regenerates* it: a new code, a new `W_rc`, the old
420 wrap deleted. The old printed code stops working, and the copy says so
421 before the user commits.
422
423### The loss modes, stated plainly
424
425| Situation | Outcome |
426|---|---|
427| Passphrase, on an enrolled device | Fine β€” the everyday case |
428| Passphrase, fresh device, kit to hand | Fine β€” scan or type the Account Key |
429| Passphrase, fresh device, no kit, but another device still enrolled | Fine β€” handoff |
430| Passphrase forgotten, kit to hand | Fine β€” the recovery code |
431| Kit lost, passphrase known, a device still enrolled | Fine β€” and the app should push hard to reprint |
432| **Passphrase known, kit lost, every device gone** | **Data is gone.** New, and caused by this decision |
433| Kit lost and passphrase forgotten | Data is gone β€” unchanged |
434
435**The last two rows are not a price; they are the product.** A vault the
436operator cannot open is a vault nobody can reopen for you β€” that is the
437entire promise, and applying it without exception is the rule working
438rather than a cost of the Account Key.
439
440What the sixth row *does* change is a feeling, not a rule. "I forgot my
441passphrase" and "I know my passphrase and lost the paper" land very
442differently on a person even though the principle is identical. So the
443obligation is not to soften the rule, it is to make sure **nobody arrives
444there by accident** β€” the prominent reprint flow, and the nag to enrol a
445second device. The README states the rule plainly and does not apologise
446for it.
447
448It also retires a promise that was never quite true once the Account Key
449existed: *"your passphrase or your codes get you back in."* The honest
450model is simpler. **The kit is the root; the passphrase is the everyday
451convenience.**
452
453## Further rulings from the review, adopted
454
455- **Optional sign-in 2FA** (TOTP, or the passkey assertion itself). It
456 gates blob release, touches no key material, and turns "the passphrase
457 alone" from full takeover into a stop at the door. Cheap and
458 A1-compatible.
459- **Move the email pepper off the instance** β€” to the routing layer or a
460 KMS β€” so a DB-plus-bucket leak alone cannot unblind addresses.
461- **Quantise ciphertext sizes**: pad items into 256-byte buckets. A1
462 tolerates size residue; that is no reason to hand over item-length
463 fingerprints for free.
464- **The sharing graph is at-rest metadata.** Wrap rows reveal who shares
465 with whom, even under A1's letter. This goes in the published model.
466 Hiding it (per-vault pseudonymous member slots) is possible and is not
467 worth the complexity now β€” recorded so the choice is visible rather than
468 accidental.
469- **Serve the web console from a separate, static, integrity-protected
470 origin** β€” signed releases, SRI, ideally an installable PWA or
471 extension β€” and say plainly that A1 for browser sessions means at-rest
472 and honest-but-curious, not actively-malicious-during-login. Native apps
473 with signed binaries genuinely meet the stronger claim.
474