carlos / contra Public

design: record two reviews, and fix a contradiction

One dated pass carrying both Fable reviews, held until the second landed
because they touch the same sections and one dated edit beats two.

From #22, committing AEAD. The KCV's stated job was backwards: its
load-bearing use is key CONFIRMATION — wrong key versus corrupted blob,
and the whole of the passkey PRF-mismatch detector — with commitment a
side effect that happens to be complete exactly where it is needed.
Recorded that HMAC-SHA-256(KEK, label)[..16] IS HKDF-Expand minus a
counter byte, i.e. the Albertini key-derivation commitment rather than a
hand-rolled substitute, and that keysets and items need no KCV because
nobody outside the client can enumerate the keys. Committing AEAD and
XAES recorded as considered and rejected, with the reason, so it is not
relitigated: reopening a settled decision wants a new fact, and all three
papers predate this document.

From #23, storage posture. Eager rotation now says so explicitly and
resolves a conflict with AGENTS.md decision 1, which reads as lazy — and
gives the residue reason rather than a performance one: lazy needs a
key-generation column that tells the operator which items changed since a
removal.

Also from #23, the at-rest and transport residue that was true and
unwritten: item count, per-item write timing and size class, which item a
write touched, and sign-in timing from the framework's sessions table in
the same replicated file. Reads leak nothing per item and that is
deliberate, so it is stated as a property to keep.

And rollback, which we came close to overclaiming. The version in the AAD
stops RELABELLING, not rollback: a server serving the whole older triple
consistently produces an item that opens, because it is a genuinely older
authentic item. Named as post-v1 with a note that no copy may claim it
until it exists.

Fixed a contradiction between two sections written a day apart: the
compromise matrix called the Account Key card "the document you may
photograph without guilt" while the Emergency Kit section says not to
encourage photographing it, and explains why — it is the second factor
protecting the at-rest copy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev d1623b2fc365f70836d9ab5b837585a3c860660c parent 6413cdb
1 file changed, +81 −4
  • DESIGN.md +81 −4

CI failed — run details

CI log
=== vet ===
go vet ./...
go: downloading gorm.io/gorm v1.31.2
go: downloading github.com/carlosframework/rastrillo v0.23.0
go: downloading github.com/go-chi/chi/v5 v5.3.2
go: downloading golang.org/x/crypto v0.55.0
go: downloading github.com/jinzhu/now v1.1.5
go: downloading golang.org/x/text v0.41.0
go: downloading github.com/jinzhu/inflection v1.0.0
go: downloading gorm.io/plugin/dbresolver v1.6.2
go: downloading modernc.org/sqlite v1.55.0
go: downloading golang.org/x/sys v0.47.0
go: downloading modernc.org/libc v1.74.1
go: downloading github.com/google/uuid v1.6.0
go: downloading modernc.org/memory v1.11.0
go: downloading github.com/dustin/go-humanize v1.0.1
go: downloading modernc.org/mathutil v1.7.1
go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec
=== fmt ===
=== test ===
go test ./...
?   	github.com/carlosframework/contra/cmd/contra	[no test files]
?   	github.com/carlosframework/contra/internal/contra	[no test files]
?   	github.com/carlosframework/contra/internal/contra/auth	[no test files]
ok  	github.com/carlosframework/contra/internal/contra/crypto	0.422s
?   	github.com/carlosframework/contra/internal/contra/icons	[no test files]
?   	github.com/carlosframework/contra/internal/contra/mail	[no test files]
?   	github.com/carlosframework/contra/internal/contra/model	[no test files]
?   	github.com/carlosframework/contra/internal/contra/store	[no test files]
?   	github.com/carlosframework/contra/internal/contra/vault	[no test files]
ok  	github.com/carlosframework/contra/internal/contratest	9.624s
=== js-test ===
node --test test/crypto.test.mjs test/items.test.mjs test/qr.test.mjs test/kit.test.mjs
TAP version 13
# Subtest: HKDF: every contra/v1 label
ok 1 - HKDF: every contra/v1 label
  ---
  duration_ms: 10.103316
  type: 'test'
  ...
# Subtest: KCV
ok 2 - KCV
  ---
  duration_ms: 2.202828
  type: 'test'
  ...
# Subtest: P-256 from a seed, including the rejection branch
ok 3 - P-256 from a seed, including the rejection branch
  ---
  duration_ms: 12.703534
  type: 'test'
  ...
# Subtest: signed challenge round-trips against the derived public key
ok 4 - signed challenge round-trips against the derived public key
  ---
  duration_ms: 5.196914
  type: 'test'
  ...
# Subtest: Crockford: alphabet, encoding, normalisation, strict decoding
ok 5 - Crockford: alphabet, encoding, normalisation, strict decoding
  ---
  duration_ms: 1.042981
  type: 'test'
  ...
# Subtest: the printed code, and the O/I/L confusions it has to survive
ok 6 - the printed code, and the O/I/L confusions it has to survive
  ---
  duration_ms: 2.153955
  type: 'test'
  ...
# Subtest: repair recovers one substituted character
ok 7 - repair recovers one substituted character
  ---
  duration_ms: 47.474483
  type: 'test'
  ...
# Subtest: AEAD: the AAD binding and every wrap
ok 8 - AEAD: the AAD binding and every wrap
  ---
  duration_ms: 2.767756
  type: 'test'
  ...
# Subtest: AEAD: the item, scoped to its vault
ok 9 - AEAD: the item, scoped to its vault
  ---
  duration_ms: 1.12289
  type: 'test'
  ...
# Subtest: a transplanted ciphertext does not open
ok 10 - a transplanted ciphertext does not open
  ---
  duration_ms: 1.931961
  type: 'test'
  ...
# Subtest: padding quantises into 256-byte buckets and round-trips
ok 11 - padding quantises into 256-byte buckets and round-trips
  ---
  duration_ms: 0.936282
  type: 'test'
  ...
# Subtest: the KDF parameter floor refuses what a malicious server would offer
ok 12 - the KDF parameter floor refuses what a malicious server would offer
  ---
  duration_ms: 1.184606
  type: 'test'
  ...
# Subtest: Argon2id matches the published PHC reference vector
ok 13 - Argon2id matches the published PHC reference vector
  ---
  duration_ms: 1823.868372
  type: 'test'
  ...
# Subtest: Argon2id at the corners the 64 MiB case does not reach
ok 14 - Argon2id at the corners the 64 MiB case does not reach
  ---
  duration_ms: 216.409146
  type: 'test'
  ...
# Subtest: Argon2id matches the golden vector at the parameters DESIGN.md pins
ok 15 - Argon2id matches the golden vector at the parameters DESIGN.md pins
  ---
  duration_ms: 2605.207522
  type: 'test'
  ...
# Subtest: the account chain, end to end
ok 16 - the account chain, end to end
  ---
  duration_ms: 2615.170273
  type: 'test'
  ...
# Subtest: an item round-trips through create, list and update
ok 17 - an item round-trips through create, list and update
  ---
  duration_ms: 10.996845
  type: 'test'
  ...
# Subtest: nothing readable reaches the network
ok 18 - nothing readable reaches the network
  ---
  duration_ms: 2.58775
  type: 'test'
  ...
# Subtest: item ciphertexts are quantised into 256-byte buckets
ok 19 - item ciphertexts are quantised into 256-byte buckets
  ---
  duration_ms: 1.589656
  type: 'test'
  ...
# Subtest: an item does not open in another slot
ok 20 - an item does not open in another slot
  ---
  duration_ms: 2.817362
  type: 'test'
  ...
# Subtest: every field is present whether or not it was filled in
ok 21 - every field is present whether or not it was filled in
  ---
  duration_ms: 1.271498
  type: 'test'
  ...
# Subtest: the Account Key cache is storage handed in, not reached for
ok 22 - the Account Key cache is storage handed in, not reached for
  ---
  duration_ms: 0.353666
  type: 'test'
  ...
# Traceback (most recent call last):
#   File "/var/lib/amadan-runner/w2/repos/carlos__contra/wt/web__shared-vaults/test/pdforacle.py", line 25, in <module>
#     import numpy as np
# ModuleNotFoundError: No module named 'numpy'
# Subtest: both documents render, scan, and read back
not ok 23 - both documents render, scan, and read back
  ---
  duration_ms: 128.662061
  type: 'test'
  location: '/var/lib/amadan-runner/w2/repos/carlos__contra/wt/web__shared-vaults/test/kit.test.mjs:41:1'
  failureType: 'testCodeFailure'
  error: |-
    Command failed: python3 /var/lib/amadan-runner/w2/repos/carlos__contra/wt/web__shared-vaults/test/pdforacle.py
    Traceback (most recent call last):
      File "/var/lib/amadan-runner/w2/repos/carlos__contra/wt/web__shared-vaults/test/pdforacle.py", line 25, in <module>
        import numpy as np
    ModuleNotFoundError: No module named 'numpy'
    
  code: 'ERR_TEST_FAILURE'
  stack: |-
    genericNodeError (node:internal/errors:983:15)
    wrappedFn (node:internal/errors:537:14)
    checkExecSyncError (node:child_process:916:11)
    execFileSync (node:child_process:952:15)
    ask (file:///var/lib/amadan-runner/w2/repos/carlos__contra/wt/web__shared-vaults/test/kit.test.mjs:33:15)
    TestContext.<anonymous> (file:///var/lib/amadan-runner/w2/repos/carlos__contra/wt/web__shared-vaults/test/kit.test.mjs:45:20)
    async Test.run (node:internal/test_runner/test:1054:7)
    async startSubtestAfterBootstrap (node:internal/test_runner/harness:296:3)
  ...
# Subtest: neither document carries the other's secret
ok 24 - neither document carries the other's secret
  ---
  duration_ms: 8.262336
  type: 'test'
  ...
# Subtest: the writer refuses characters it cannot draw
ok 25 - the writer refuses characters it cannot draw
  ---
  duration_ms: 1.033356
  type: 'test'
  ...
# Traceback (most recent call last):
#   File "/var/lib/amadan-runner/w2/repos/carlos__contra/wt/web__shared-vaults/test/qroracle.py", line 30, in <module>
#     import numpy as np
# ModuleNotFoundError: No module named 'numpy'
# Traceback (most recent call last):
#   File "/var/lib/amadan-runner/w2/repos/carlos__contra/wt/web__shared-vaults/test/qroracle.py", line 30, in <module>
#     import numpy as np
# ModuleNotFoundError: No module named 'numpy'
# Subtest: every symbol scans, and is the symbol another encoder would have made
not ok 26 - every symbol scans, and is the symbol another encoder would have made
  ---
  duration_ms: 136.700986
  type: 'test'
  location: '/var/lib/amadan-runner/w2/repos/carlos__contra/wt/web__shared-vaults/test/qr.test.mjs:57:1'
  failureType: 'testCodeFailure'
  error: 'spawnSync python3 EPIPE'
  code: 'EPIPE'
  stack: |-
    Object.spawnSync (node:internal/child_process:1120:20)
    spawnSync (node:child_process:902:24)
    execFileSync (node:child_process:945:15)
    ask (file:///var/lib/amadan-runner/w2/repos/carlos__contra/wt/web__shared-vaults/test/qr.test.mjs:49:15)
    TestContext.<anonymous> (file:///var/lib/amadan-runner/w2/repos/carlos__contra/wt/web__shared-vaults/test/qr.test.mjs:68:20)
    Test.runInAsyncScope (node:async_hooks:214:14)
    Test.run (node:internal/test_runner/test:1047:25)
    Test.start (node:internal/test_runner/test:944:17)
    startSubtestAfterBootstrap (node:internal/test_runner/harness:296:17)
  ...
# Subtest: the automatically chosen mask still scans
not ok 27 - the automatically chosen mask still scans
  ---
  duration_ms: 120.971465
  type: 'test'
  location: '/var/lib/amadan-runner/w2/repos/carlos__contra/wt/web__shared-vaults/test/qr.test.mjs:82:1'
  failureType: 'testCodeFailure'
  error: |-
    Command failed: python3 /var/lib/amadan-runner/w2/repos/carlos__contra/wt/web__shared-vaults/test/qroracle.py
    Traceback (most recent call last):
      File "/var/lib/amadan-runner/w2/repos/carlos__contra/wt/web__shared-vaults/test/qroracle.py", line 30, in <module>
        import numpy as np
    ModuleNotFoundError: No module named 'numpy'
    
  code: 'ERR_TEST_FAILURE'
  stack: |-
    genericNodeError (node:internal/errors:983:15)
    wrappedFn (node:internal/errors:537:14)
    checkExecSyncError (node:child_process:916:11)
    execFileSync (node:child_process:952:15)
    ask (file:///var/lib/amadan-runner/w2/repos/carlos__contra/wt/web__shared-vaults/test/qr.test.mjs:49:15)
    TestContext.<anonymous> (file:///var/lib/amadan-runner/w2/repos/carlos__contra/wt/web__shared-vaults/test/qr.test.mjs:87:19)
    Test.runInAsyncScope (node:async_hooks:214:14)
    Test.run (node:internal/test_runner/test:1047:25)
    Test.processPendingSubtests (node:internal/test_runner/test:744:18)
    Test.postRun (node:internal/test_runner/test:1173:19)
  ...
# Subtest: a printed code is a version 3 symbol at EC-Q
ok 28 - a printed code is a version 3 symbol at EC-Q
  ---
  duration_ms: 1.484397
  type: 'test'
  ...
# Subtest: it refuses what it cannot encode
ok 29 - it refuses what it cannot encode
  ---
  duration_ms: 0.624701
  type: 'test'
  ...
1..29
# tests 29
# suites 0
# pass 26
# fail 3
# cancelled 0
# skipped 0
# todo 0
# duration_ms 7419.495084
make: *** [Makefile:95: test-js] Error 1

amadan: step js-test failed: exit status 2

amadan: job failed at step js-test — its output is above, not at the tail of this log
diff --git a/DESIGN.md b/DESIGN.md
index b3c5b76..cf000fa 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -98,9 +98,43 @@ ceremony.
- Golden vectors cover Argon2id, every HKDF label, Crockford
normalisation and the checksum. Cross-platform KDF drift is exactly the
bug class behind the 1Password PRF mismatch.
-- AES-GCM is not key-committing, so the per-wrap KCV is the commitment
- check — and, just as usefully, it distinguishes "wrong key" from
- "corrupted blob", a distinction the unlock paths need constantly.
+- **The per-wrap KCV is a key *confirmation* check first.** Its everyday
+ job is distinguishing "wrong key" from "corrupted blob" — a distinction
+ the unlock paths need constantly, and the whole of the passkey
+ PRF-mismatch detector below. AES-GCM is also not key-committing, and the
+ KCV supplies that too as a side effect, on exactly the rows that need
+ it. Earlier drafts had this the other way round, which made the
+ commitment sound load-bearing and the confirmation incidental.
+
+ `HMAC-SHA-256(KEK, label)[..16]` **is** `HKDF-Expand(KEK, label)` minus
+ a counter byte — that is, the Albertini et al. key-derivation
+ commitment, not a hand-rolled substitute for one.
+
+ Keysets and items carry no KCV and need none: nobody but the client can
+ hold or enumerate RK, `K_vault`, or an ECDH-derived key, so a multi-key
+ ciphertext cannot be built against them. Every key-commitment attack —
+ partitioning oracles, invisible salamanders — requires the attacker to
+ choose or enumerate the keys the victim opens under, and nobody in the
+ compromise matrix below can. `W_pw` is the only branch with a dictionary
+ at all, it is gated on the Account Key, and the KCV is checked *before*
+ decryption, so a crafted multi-key ciphertext gets past it for at most
+ one key.
+
+ **Committing AEAD and XAES-256-GCM: considered and rejected**
+ (2026-09-02, discussion 22). The property is already present where it is
+ needed; the commitment here is a property of the decryption *procedure*
+ rather than of the bytes, so a second implementation that skips the KCV
+ check loses it — that is discipline to enforce, not a risk being
+ carried. If commitment is ever wanted for items, the tool is 32 zero
+ bytes prefixed to the plaintext, not a cipher change. Reopening this
+ wants a new fact; Albertini 2020, Len–Grubbs–Ristenpart 2021 and XAES
+ 2024 all predate this document, which already named the non-committing
+ property.
+
+ Nit, recorded and not acted on: the KCV is unsalted, so two `W_prf` rows
+ with equal KEKs have equal KCVs — telling the server that two providers
+ return the same PRF, which the random nonces would otherwise hide.
+ Salting with the wrap id removes it and changes the golden vectors.
## The recovery code
@@ -174,6 +208,18 @@ items can still be sealed to `vP_j` with no unlock at all.
re-encrypted, re-wrapped to members. **Run it automatically on member
removal** — in a seal-to-public-key model, revocation *is* rotation,
because the removed member may have cached `K_vault`.
+
+ **Eager, not lazy, and this is the resolution of a conflict** (2026-09-02,
+ discussion 23). `AGENTS.md` decision 1 says the key "rotates forward for
+ new and changed items", which reads as lazy; this document says items are
+ re-encrypted on removal, which is eager. Eager wins, for a residue
+ reason rather than a performance one: lazy needs a per-row
+ key-generation column, and that column tells the operator which items
+ changed since a member was removed. Re-encrypting N rows is the same
+ bytes as rewriting one blob and the CPU is negligible; what matters is
+ that it is **one transaction**, guarded by a compare-and-swap on the
+ vault epoch, so a concurrent writer gets a conflict rather than writing
+ an item under the key that was just revoked.
- **Recovery-code regeneration** is a new code, a new `W_rc`, old wrap
deleted. "I want a fresh printout" is regeneration; "the paper was
exposed" is credential rotation.
@@ -192,9 +238,40 @@ items can still be sealed to `vP_j` with no unlock at all.
| **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 |
| **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 |
| **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 |
-| **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 — which is why this is the document you may photograph without guilt |
+| **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 |
| **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 |
+### At-rest and transport residue, written down (2026-09-02, discussion 23)
+
+The rows above say what an attacker gets from *keys*. These are what the
+stored bytes give up regardless, and they were true before they were
+written here, which is the reason for writing them here.
+
+- **Item count per vault**, from the row count. One blob per vault would
+ hide it; that is rejected for v1 in the same discussion, because
+ drop-box and agent writers cannot produce the next blob and "one blob"
+ becomes "one blob plus an inbox table" whose rows are the most sensitive
+ class of item there is — captured, unreviewed secrets.
+- **Per-item write timing and size class.** Reads leak nothing per item:
+ `/api/items` returns the whole vault and there is no per-item GET. That
+ property is deliberate and worth keeping.
+- **Which item a write touched**, in transport.
+- **Sign-in timing per account**, from the framework's `sessions` table,
+ which lives in the same replicated file. The merciless greps scope their
+ DDL check to CONTRA's own tables by design, so this is not a test gap —
+ but it is residue and belongs here.
+- **The sharing graph** — which accounts share a vault, never what is in
+ it. Already published in the README's trade-offs.
+
+**Rollback is not prevented, and nothing here should be read as claiming
+it is.** The version bound into each item's AAD stops *relabelling*: a
+server cannot serve ciphertext `v3` as `v7`. It does not stop a server
+consistently serving the whole older `(version, nonce, ciphertext)` triple,
+which opens correctly because it is genuinely an older authentic item.
+Detecting that needs client-side memory of the highest version seen per
+item, and for multiple devices a sealed manifest. Post-v1; until it
+exists, no copy may claim rollback protection.
+
## The Emergency Kit — settled (Paul, 2026-09-01)
**Decided: we print the Account Key too, on the same client-generated PDF