Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

C3 — Encryption is a data format

From cognokratos/arktos-wallet · docs/capability/03-encryption-is-a-data-format.md · pinned revision 92650a034799

Ciphertext is long-lived structured data. Design it like a versioned protocol.

Question: a recovery phrase encrypted today may need to be decrypted in ten years, by a binary that does not exist yet, after the algorithm, the key or the library has changed. What has to be written down next to the ciphertext for that to work, and what must never vary?

Mental model

An encrypt(key, plaintext) → bytes function is not a storage format. Stored ciphertext needs:

NeedWhyArktos v1
A versionThe decryptor has to know which rules produced these bytes before it tries anything"v": 1
An algorithm identifierCrypto agility: an algorithm can be replaced without guessing at old data"alg": "A256GCM"
A fresh nonce per encryptionAES-GCM's confidentiality and integrity collapse if a (key, nonce) pair is ever reused12 random bytes from the OS RNG, "nonce"
Authenticated ciphertextTampering must be detected, not silently decrypted into garbageAES-256-GCM: "ct" is ciphertext followed by a 16-byte tag
Associated data (AAD)Binds the context the ciphertext is valid in, without storing it secretlyarktos:v1:A256GCM:wallet-seed
Uniform failure on authenticationThe error must not tell an attacker why decryption failedCryptoError::DecryptionFailed for every authentication failure

The stored value in wallets.encrypted_passphrase is compact JSON:

{"v":1,"alg":"A256GCM","nonce":"<base64url, 12 bytes>","ct":"<base64url ciphertext‖16-byte tag>"}

The AAD is never stored. Both sides compute it from code (AeadKey::aad in src/crypto.rs):

arktos:v1:A256GCM:<purpose>        e.g. arktos:v1:A256GCM:wallet-seed

Because the version and the algorithm are inside the AAD, a ciphertext produced under v1 rules cannot be accepted under some future v2 rules, and the reverse is also true, even if someone edits the v field. Because the purpose is inside the AAD, a ciphertext sealed for wallet-seed will not open under a key whose purpose is anything else, even if the key bytes happen to be identical.

In the code

src/crypto.rs is short enough to read in full. open checks things in a deliberate order:

1. parse only {"v"}                → MalformedEnvelope        (not JSON / no v)
2. v != 1                          → UnsupportedVersion(v)    (explicit, before anything else)
3. parse full EnvelopeV1, no extra fields → MalformedEnvelope
4. alg != "A256GCM"                → UnsupportedAlgorithm
5. nonce not 12 bytes              → InvalidNonce
6. ct shorter than the 16-byte tag → MalformedEnvelope
7. AES-256-GCM decrypt with AAD    → DecryptionFailed         (wrong key, wrong purpose,
                                                               tampered nonce, ct or tag)

Steps 1–6 look only at public structure: anyone who can read the envelope already knows the answers, so naming the problem leaks nothing. Step 7 is the only step that depends on the secret key, and all of its failures collapse into one variant. A decryptor that reported "tag mismatch" separately from "wrong key", or that returned partially decrypted bytes, would be handing an attacker an oracle.

The collapse does not stop at CryptoError. Over MCP, every crypto, derivation and storage fault becomes the same JSON-RPC -32603 "internal error", with no data attached (AppError in src/error.rs). Details such as crypto failure: wallet 7: failed to decrypt secret go only to the server log, and they contain no secret.

Lab

These are tests over synthetic keys ([1; 32], [2; 32]) and a public test vector. Nothing real is decrypted.

cargo test --lib crypto::tests

Before reading each test, predict which CryptoError the change produces. Then check:

#ChangeTestError class
1Encrypt the same plaintext repeatedlynonces_and_ciphertexts_are_uniquen/a: every nonce and every ciphertext differs
2Flip a bit in the ciphertext (or in the tag at its end)modified_ciphertext_failsDecryptionFailed
3Flip a bit in the noncemodified_nonce_failsDecryptionFailed
4Truncate the nonce to 8 byteswrong_nonce_length_failsInvalidNonce
5Change alg to A128GCMunknown_algorithm_fails_explicitlyUnsupportedAlgorithm
6Change v to 2unknown_version_fails_explicitlyUnsupportedVersion(2)
7Same key bytes, different purposepurpose_is_bound_to_ciphertextDecryptionFailed
8Different keywrong_key_failsDecryptionFailed
9Truncated JSON, bare base64, extra fieldstruncated_envelopes_fail_cleanly, invalid_encoding_fails_cleanlyMalformedEnvelope
10Render errors and keys with {} and {:?}errors_and_debug_do_not_leak_secretsno plaintext and no key bytes in any output

Changes 3, 7 and 8 are three different mistakes, but they produce the same error. Ask yourself why that is the correct behavior, and what you would lose if the three were distinguishable.

Then follow a broken envelope end to end:

cargo test --test secret_storage_tests non_envelope_values_are_rejected
cargo test --test secret_storage_tests wallet_is_unreadable_with_a_different_master_key
cargo test --lib error::tests::server_faults_hide_details

Inspect live: ciphertext without plaintext

With the lab server running, create a wallet and look at what is stored. You only ever read metadata, never the decrypted value:

mcp "$A" create_wallet '{"wallet_name":"main"}'
mcp "$A" create_wallet '{"wallet_name":"savings"}'
labsql "SELECT id, name,
               json_extract(encrypted_passphrase, '$.v')   AS v,
               json_extract(encrypted_passphrase, '$.alg') AS alg,
               length(json_extract(encrypted_passphrase, '$.ct')) AS ct_b64_len
        FROM wallets;"

Notice that ct_b64_len differs between wallets. AES-GCM hides content but not length: the ciphertext is exactly as long as the plaintext plus the 16-byte tag. A 12-word English phrase is between 47 and 107 characters (BIP39 English words have 3–8 letters), so the length reveals something about which words were chosen. The leak is small, but it is real. Fixed-length padding before sealing would remove it, and adding padding would be an envelope-format change: it would need a new version.

What v1 does not bind

The AAD binds version, algorithm and purpose. It does not bind the envelope to the row it is stored in: neither the wallet id nor the owning API key is part of it. In practice this means:

  • Someone who can write to the database (who has DATABASE_KEY and file access) but lacks MASTER_KEY cannot read any phrase. Confidentiality holds.
  • That same person can copy one wallet's envelope into another wallet's row. It would open cleanly, and future derivations for the victim's wallet would produce the source wallet's addresses. The same person could also edit the public accounts rows directly, because stored accounts are served without being re-derived.

So field encryption in v1 gives confidentiality against database-level access. It does not give integrity of the wallet-to-owner binding, or of stored public data, against someone who can write to the database. Defending against a writer would mean binding the row identity, for example wallet_id and key_id, into the AAD, which is a new envelope version. It would also mean authenticating or re-deriving the accounts rows. Both belong to Challenge 3. Arktos's threat model treats database write access as operator-level, and this section exists so that you know exactly where that line sits.

Failure mode

  • Unversioned ciphertext. The first migration then has to guess how each value was produced.
  • Nonce reuse, for example from a counter that resets on restart, or from deriving the nonce from the plaintext.
  • Unauthenticated encryption (CBC or CTR without a MAC), which turns tampering into silent corruption.
  • Distinguishable authentication errors, which give an attacker an oracle.
  • Assuming that "encrypted" also means "bound to its context". It does only if the context is in the AAD.

Takeaway

Ciphertext is long-lived structured data. Design it like a versioned protocol.

Previous: C2 · Next: C4 — Minimize secret lifetimes

This chapter is maintained in cognokratos/arktos-wallet beside the code it teaches. The book shows docs/capability/03-encryption-is-a-data-format.md at revision 92650a0347993622cbb3e5eeac2c908069c67fd6 (branch main). View source at this revision · Report a correction.

Corrections are made upstream against the current main branch and appear here when the book's pin for this source is updated.