fix(credentials): mark the encrypted store so a random nonce cannot fake plaintext

The on-disk format was detected by sniffing the first byte for `[` or `{`.
Encrypted blobs begin with a random 12-byte nonce, so roughly 1 in 128
saves produced a valid encrypted file whose first byte was 0x5B or 0x7B;
those were misread as plaintext JSON, failed `String::from_utf8`, and the
store became permanently unreadable. This was surfacing as a flaky
`test_list_credentials_no_filter`, but it is a real data-loss bug: a node
whose ciphertext happened to start with one of those bytes could not load
its credentials.

Writes now carry a fixed `ARCHYCRED1` marker, which cannot collide with a
random nonce, so detection of the current format is exact.

Legacy unmarked files are detected by SUCCESSFUL AEAD DECRYPTION rather
than by another byte sniff. A verifying Poly1305 tag under the node key is
a cryptographic discriminator (~2^-128 false-positive rate), strictly
stronger than any structural guess — which is why the deferred item's
suggested "keep the first-byte sniff as the legacy fallback" was not the
shape adopted. Plaintext JSON remains the last resort, and is still
reachable on a node that has no node key at all.

An undecodable file now errors instead of returning an empty store, so a
transiently unreadable file is never silently replaced by an empty one
that the next save would commit to disk (CLAUDE.md: migrations never
destroy data). Legacy files upgrade on write, never on read.

Tests drive the collision deterministically via an explicit nonce rather
than waiting on the 1-in-128 draw, and cover all three on-disk
populations, the read-path-does-not-rewrite guarantee, and tamper
rejection. 28 passed, 0 failed.

Closes the 10-01 deferred item.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-02 14:15:01 -04:00
co-authored by Claude Opus 5
parent 937d836c53
commit c5a82cba06
2 changed files with 299 additions and 13 deletions
@@ -0,0 +1,31 @@
# Deferred items — Phase 10
Out-of-scope discoveries found while executing this phase. Logged, not fixed.
## From 10-01 (KEY-01 / F-01)
**Flaky test: `credentials::operations::tests::test_list_credentials_no_filter`**
- Discovered: 2026-08-02, during the post-plan full-suite run (`cargo test -p archipelago`).
- Symptom: `called Result::unwrap() on an Err value: UTF-8 credentials / invalid utf-8 sequence
of 1 bytes from index 3`.
- Root cause (read, not fixed): `credentials/store.rs:29` sniffs the FIRST BYTE of the stored
blob for `[` or `{` to distinguish a plaintext-JSON legacy store from the encrypted binary
one. When the encrypted ciphertext happens to begin with `0x5B` or `0x7B` — about a 1-in-128
chance per run — the encrypted store is misread as plaintext and `String::from_utf8` fails.
This is a real bug in the migration sniffing, not just a test problem: a real node whose
credential ciphertext starts with one of those bytes cannot load its credentials.
- Why deferred: unrelated to KEY-01, different subsystem, untouched by this plan
(`git status` shows `credentials/` unmodified). Fixing it means adding a format marker or
version header to the store, which is an envelope change.
- Suggested fix: prepend an explicit magic/version byte on write and branch on that, keeping the
first-byte sniff only as the legacy fallback.
- **RESOLVED 2026-08-02** — fixed along the suggested lines, with one improvement. Writes are now
prefixed with a fixed `ARCHYCRED1` marker, which cannot collide with a random nonce. Legacy
unmarked files are detected by *successful AEAD decryption* rather than by a byte sniff: a
Poly1305 tag that verifies under the node key is a cryptographic discriminator (~2^-128 false
positive), strictly stronger than the structural sniff the fallback would have kept. Plaintext
JSON stays the last resort, and an undecodable file now errors instead of silently becoming an
empty store that the next save would overwrite. Legacy files upgrade on write, never on read.
Regression tests drive the collision deterministically via an explicit nonce (`0x5B`/`0x7B`)
instead of waiting on the 1-in-128 draw.