carlos / contra Public

vectors: pin the crypto contract before any implementation can claim it

Three languages will implement this key hierarchy and DESIGN.md says they
must be byte-identical. Whichever one got written first would otherwise
become the de facto reference, and cross-platform KDF drift is exactly the
bug class behind the 1Password PRF mismatch our recovery design exists to
survive. So the vectors land first, from the spec.

The generator is C against OpenSSL rather than Go, deliberately. Generating
vectors from an implementation and then testing that implementation against
them proves only that the code agrees with itself; discussion 6 names that
trap explicitly. gen.c re-computes the published PHC, RFC 5869, RFC 4231 and
NIST CAVP vectors and aborts on any mismatch before generating a byte of
ours, so "independent" means "anchored outside this repo" rather than merely
"not the same code".

Every fixture input derives from its own published label, so the file
regenerates from the source alone and nothing in it can be mistaken for, or
reused as, a real secret.

seedsearch exists for one value: a rejection in the P-256 sampling loop is a
2^-32 event, so no hand-picked seed ever reaches that branch. The witness it
found is pinned in the Makefile and verified independently by the openssl
CLI and by RFC 5869 written out longhand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hz8mMZYs75WuRC1hYpmapJ
Paul Campbell pushed by paul@keymail.dev 4658067799761d84e9586308a096212461587b9d parent a13df93
5 files changed, +1477 −0
  • vectors/README.md +68 −0
  • vectors/contra-v1.json +231 −0
  • vectors/gen/Makefile +35 −0
  • vectors/gen/gen.c +977 −0
  • vectors/gen/seedsearch.c +166 −0
diff --git a/vectors/README.md b/vectors/README.md
new file mode 100644
index 0000000..412c235
--- /dev/null
+++ b/vectors/README.md
@@ -0,0 +1,68 @@
+# ๐Ÿค– Golden vectors โ€” the cross-language crypto contract
+
+`contra-v1.json` is the reference for every derivation in `DESIGN.md`.
+
+CONTRA implements the same crypto three times: Go on the instance, JavaScript
+in the console, Swift on the Mac. They must produce identical bytes. **All
+three suites pass this one file, or the contract is not being kept** โ€” a second
+implementation that passes vectors of its own has proved nothing, and cross-
+platform KDF drift is the bug class behind the 1Password PRF mismatch that
+shaped our whole recovery design.
+
+Changing a value in this file is changing the protocol.
+
+## What it pins
+
+Argon2id at `m=64MiB, t=3, p=1`; all five `contra/v1/*` HKDF labels; the KCV
+construction; P-256 derivation from a seed, including a seed that forces the
+rejection branch; Crockford Base32 with the Oโ†’0 and I/Lโ†’1 mappings; the 20-bit
+checksum and a single-substitution repair; AES-256-GCM with its AAD binding.
+`account_chain` is the whole hierarchy end to end, so a new implementation can
+bisect where it diverges rather than staring at one wrong key.
+
+The wire formats `DESIGN.md` leaves open โ€” HKDF mode, the AAD layout, Crockford
+packing, the rejection loop โ€” are pinned in **discussion 19**. Read that before
+implementing against this file.
+
+## Where the numbers come from
+
+The generator is `gen/gen.c`, **C against OpenSSL** โ€” deliberately not Go.
+
+Generating vectors from an implementation and then testing that implementation
+against them proves only that the code agrees with itself. So the vectors come
+from an implementation nobody here wrote, and the Go package is only ever the
+thing under test. If you are ever tempted to regenerate this file to make a
+test pass, stop: that is the exact failure the arrangement exists to prevent.
+Fix the code, or argue on the record that the spec is wrong.
+
+`gen.c` also re-computes published third-party vectors โ€” the PHC Argon2id
+reference outputs, RFC 5869, RFC 4231, NIST CAVP AES-GCM โ€” and aborts if any
+disagrees. Otherwise "independent" would only mean the two implementations are
+not the same, not that either is right.
+
+Every input is generated from its own published label:
+
+ fixture(label, n) = SHA-256("contra/v1/vectors/" || label)[0..n]
+
+so the file regenerates from the source alone, and nothing in it is, or may be
+reused as, a real secret (`AGENTS.md` decision 8).
+
+## Regenerating
+
+ cd gen && make vectors
+
+`REJECT_K` in the `Makefile` is the seed witness that forces a P-256 rejection.
+It is pinned rather than searched each time because finding one costs a few
+billion HKDF evaluations โ€” a rejection is a ~2^-32 event. If the
+`contra/v1/ec-p256` label ever changes, the witness dies with it: run
+`make search` (minutes, all cores) and pin the new `k`.
+
+## Verifying the Go side
+
+ go test ./internal/crypto/
+
+The suite has been mutation-checked: lowering the Argon2 time cost, typoing an
+HKDF label, truncating the KCV, dropping the AAD length prefixes or its domain
+tag, skipping the rejected EC candidate, accepting non-zero Base32 pad bits,
+widening the checksum, and dropping the O/I/L mappings each turn it red. A
+vector nobody has watched fail is a vector nobody should trust.
diff --git a/vectors/contra-v1.json b/vectors/contra-v1.json
new file mode 100644
index 0000000..a94af55
--- /dev/null
+++ b/vectors/contra-v1.json
@@ -0,0 +1,231 @@
+{
+ "format": "contra-golden-vectors",
+ "version": 1,
+ "spec": "DESIGN.md; wire formats pinned in discussion 19",
+ "generator": "vectors/gen/gen.c against OpenSSL 3.5.6 7 Apr 2026",
+ "note": "Every input here is generated: fixture(label) = SHA-256(\"contra/v1/vectors/\" || label). Nothing in this file is a real secret, and nothing in it may be reused as one.",
+ "binding": "The Go, JS and Swift crypto suites must all pass this file. Changing a value here is changing the protocol.",
+ "external_anchors": {
+ "note": "Published third-party vectors. gen.c recomputes each one and aborts on mismatch before generating anything below.",
+ "sources": ["PHC argon2 reference vectors", "RFC 5869 cases 1 and 3", "RFC 4231 case 1", "NIST CAVP AES-256-GCM"]
+ },
+ "argon2id": {
+ "params": {"m_kib": 65536, "t": 3, "p": 1, "tag_len": 32},
+ "floor": {"m_kib": 65536, "t": 3, "note": "clients refuse anything weaker a server offers"},
+ "cases": [
+ {
+ "name": "S_pw",
+ "passphrase_utf8": "contra-golden-vector-passphrase-do-not-use-8f3a1c",
+ "passphrase_hex": "636f6e7472612d676f6c64656e2d766563746f722d706173737068726173652d646f2d6e6f742d7573652d386633613163",
+ "salt_hex": "962c1e2ca2b4c0602e6ec706e76e3eac",
+ "out_hex": "6bf83f20da63fb1c818ce0f12e93294ba999b4ad156a7e5ae680f9f38fdd493b"
+ }
+ ]
+ },
+ "hkdf": {
+ "construction": "RFC 5869 extract-then-expand, SHA-256; an absent salt is zero-length; info is the label's ASCII bytes with no NUL",
+ "cases": [
+ {
+ "name": "seed_login",
+ "info": "contra/v1/login",
+ "ikm_hex": "6bf83f20da63fb1c818ce0f12e93294ba999b4ad156a7e5ae680f9f38fdd493b",
+ "salt_hex": "",
+ "l": 32,
+ "okm_hex": "4f2b905bcff9d01c15bbb82bb0a9d26a1267f35278928d1bd38e8c838ef68b8a"
+ },
+ {
+ "name": "KEK_pw",
+ "info": "contra/v1/kek",
+ "ikm_hex": "6bf83f20da63fb1c818ce0f12e93294ba999b4ad156a7e5ae680f9f38fdd493b",
+ "salt_hex": "f60fb281e8c72f6725add4cbd40e7b08",
+ "l": 32,
+ "okm_hex": "e70a2f643ee0dc6123d042ca574b44994135d7776016e3d181fc32f93db40d82"
+ },
+ {
+ "name": "seed_rc_login",
+ "info": "contra/v1/rc-login",
+ "ikm_hex": "70da5ffc983510bba4672030946b9eed",
+ "salt_hex": "",
+ "l": 32,
+ "okm_hex": "46b18c0c2a54c5ef6baeeda49ab42fd24ae8401912020cf94c02a51f1436240b"
+ },
+ {
+ "name": "KEK_rc",
+ "info": "contra/v1/rc-kek",
+ "ikm_hex": "70da5ffc983510bba4672030946b9eed",
+ "salt_hex": "",
+ "l": 32,
+ "okm_hex": "58c5e4bd32d767d48a63d3f77ed67bad6c1fb4017d90a2f098cb32e8b87ff27b"
+ },
+ {
+ "name": "KEK_prf",
+ "info": "contra/v1/prf-kek",
+ "ikm_hex": "6f926e3f71aec7803ffeeef8598e91de4195a42067688c8f4e3fd24c00c50c49",
+ "salt_hex": "ce414df9c26826e10c1c4f359823885eb5eb39f89ead15583cade016d317f276",
+ "l": 32,
+ "okm_hex": "25a832e73062849b768e245a28a1139f3a0171d41a05a34ce8d2615e7ecacad9"
+ }
+ ]
+ },
+ "kcv": {
+ "construction": "HMAC-SHA-256(KEK, \"contra/v1/kcv\")[..16]",
+ "cases": [
+ {"name": "KCV_pw", "kek_hex": "e70a2f643ee0dc6123d042ca574b44994135d7776016e3d181fc32f93db40d82", "kcv_hex": "fc6587261be8f190edd03ee21eda38b4"},
+ {"name": "KCV_rc", "kek_hex": "58c5e4bd32d767d48a63d3f77ed67bad6c1fb4017d90a2f098cb32e8b87ff27b", "kcv_hex": "a4e31d3daede8f7051c4ff6a9cea876b"},
+ {"name": "KCV_prf", "kek_hex": "25a832e73062849b768e245a28a1139f3a0171d41a05a34ce8d2615e7ecacad9", "kcv_hex": "a6a270fb61f6a4dc3c8378d76784c8bc"}
+ ]
+ },
+ "p256_from_seed": {
+ "construction": "candidate_i = HKDF-SHA-256(ikm=seed, salt=\"\", info=\"contra/v1/ec-p256\" || byte(i)); accept when 1 <= OS2IP(candidate) <= n-1, else i += 1",
+ "public_key_encoding": "SEC1 uncompressed, 0x04 || X || Y",
+ "cases": [
+ {
+ "name": "LK_pw",
+ "seed_hex": "4f2b905bcff9d01c15bbb82bb0a9d26a1267f35278928d1bd38e8c838ef68b8a",
+ "candidates_tried": 1,
+ "candidates": ["ae4fd50980acd2da69b6b3d77be8f4347b36d5429639f42e0753e784718cc05b"],
+ "d_hex": "ae4fd50980acd2da69b6b3d77be8f4347b36d5429639f42e0753e784718cc05b",
+ "public_uncompressed_hex": "0465b117b08f92789048944fced0df2343d93b77e8c92448a3f2be170ea91755066a7f789fdf979c17ed782cf9f24cdec565ad8fe752e9f24e974e4c5270cb908f"
+ },
+ {
+ "name": "LK_rc",
+ "seed_hex": "46b18c0c2a54c5ef6baeeda49ab42fd24ae8401912020cf94c02a51f1436240b",
+ "candidates_tried": 1,
+ "candidates": ["5e849fc53c753f9bd0f76ad1b3d3d997c1b7837a1ee733666a69679f2fca9e01"],
+ "d_hex": "5e849fc53c753f9bd0f76ad1b3d3d997c1b7837a1ee733666a69679f2fca9e01",
+ "public_uncompressed_hex": "048b58c6f080a5fe442a996ad8c7104fc2ecc23fac14d5bfb834fbf949640dae7a4ff41f793d4d7ae97103fe8d5d13faf99b46c52f46a36ce5416d9346b8fac90f"
+ },
+ {
+ "name": "rejects_at_i0",
+ "seed_hex": "636f6e7472612f76312f766563746f722d73656172636821000000028d6ed394",
+ "candidates_tried": 2,
+ "candidates": ["ffffffff664667834724f9a77479bf20190521865ac9486dcc6c225972783d8b", "5868322caaf160cdb5b9289459bd504c3c48f6397bade3c3bf07a7f2deefd8e8"],
+ "d_hex": "5868322caaf160cdb5b9289459bd504c3c48f6397bade3c3bf07a7f2deefd8e8",
+ "public_uncompressed_hex": "040fa92cfa882ba8b5d2977fcd7d5805751d54c3530daabce40f3f8ccfdc5715ddb75a2e638f11f685eebff0ac49872d0f8033d5c871202c796ec6400acf1c9afa"
+ }
+ ]
+ },
+ "p256_rejecting_seed": {
+ "note": "A rejection has probability about 2^-32, so this seed was searched for. Without it the rejection branch is dead code in all three languages.",
+ "seed_recipe": "\"contra/v1/vector-search!\" || u64be(k)",
+ "k": 10962785172,
+ "seed_hex": "636f6e7472612f76312f766563746f722d73656172636821000000028d6ed394"
+ },
+ "crockford": {
+ "alphabet": "0123456789ABCDEFGHJKMNPQRSTVWXYZ",
+ "packing": "MSB-first over the raw bytes, left-aligned; 16 bytes -> 26 symbols with two zero pad bits. Decoding rejects a non-zero pad, so every value has exactly one encoding.",
+ "normalize": "strip '-' and whitespace, uppercase, O->0, I->1, L->1; U is not in the alphabet and is an error",
+ "encode_cases": [
+ {"bytes_hex": "00000000000000000000000000000000", "symbols": "00000000000000000000000000"},
+ {"bytes_hex": "ffffffffffffffffffffffffffffffff", "symbols": "ZZZZZZZZZZZZZZZZZZZZZZZZZW"},
+ {"bytes_hex": "f60fb281e8c72f6725add4cbd40e7b08", "symbols": "YR7V50F8RWQPE9DDTK5X83KV10"},
+ {"bytes_hex": "70da5ffc983510bba4672030946b9eed", "symbols": "E3D5ZZ4R6M8BQ93740R98TWYXM"}
+ ],
+ "normalize_cases": [
+ {"input": "c1-oi1l-0O1I", "normalized": "C101110011"},
+ {"input": " A1 oL Li ", "normalized": "A10111"},
+ {"input": "c1-abc-def", "normalized": "C1ABCDEF"},
+ {"input": "C1-UUUU", "error": "character outside the Crockford alphabet"},
+ {"input": "c1?abc", "error": "character outside the Crockford alphabet"}
+ ],
+ "decode_cases": [
+ {"symbols": "E3D5ZZ4R6M8BQ93740R98TWYXM", "bytes_hex": "70da5ffc983510bba4672030946b9eed"},
+ {"symbols": "E3D5ZZ4R6M8BQ93740R98TWYXN", "error": "non-zero pad bits in the final symbol", "rejected": true}
+ ]
+ },
+ "printed_code": {
+ "form": "tag(2) || data(26) || checksum(4), printed as TAG-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX",
+ "checksum": "first 20 bits, MSB-first, of SHA-256(payload_bytes) -- over the 16 raw bytes, not the string",
+ "cases": [
+ {"name": "account_key", "tag": "A1", "payload_hex": "f60fb281e8c72f6725add4cbd40e7b08", "checksum20": 543112, "normalized": "A1YR7V50F8RWQPE9DDTK5X83KV10GJC8", "printed": "A1-YR7V5-0F8RW-QPE9D-DTK5X-83KV1-0GJC8"},
+ {"name": "recovery_code", "tag": "C1", "payload_hex": "70da5ffc983510bba4672030946b9eed", "checksum20": 573888, "normalized": "C1E3D5ZZ4R6M8BQ93740R98TWYXMHGE0", "printed": "C1-E3D5Z-Z4R6M-8BQ93-740R9-8TWYX-MHGE0"}
+ ],
+ "repair": {
+ "note": "substitute one character, then brute-force all 30 payload positions x 32 symbols against the checksum; return every survivor in position-then-alphabet order for the caller to try against the KCV",
+ "correct_normalized": "C1E3D5ZZ4R6M8BQ93740R98TWYXMHGE0",
+ "typo_normalized": "C1E3D5Z84R6M8BQ93740R98TWYXMHGE0",
+ "typo_position": 7,
+ "candidates": ["C1E3D5ZZ4R6M8BQ93740R98TWYXMHGE0"],
+ "candidate_count": 1,
+ "correct_is_a_candidate": true
+ }
+ },
+ "aead": {
+ "algorithm": "AES-256-GCM, 96-bit nonce, 128-bit tag",
+ "aad": "\"contra/v1/aad\" || 0x00 || u32be(len(scope_id)) || scope_id || u32be(len(object_type)) || object_type || u32be(len(object_id)) || object_id || u64be(version)",
+ "scope_id": "the account id for account-scoped objects, the vault id for vault-scoped ones",
+ "account_id_hex": "b22c6a5eae1be9d6a773f474fd0532b0",
+ "vault_id_hex": "d7331f78b34eb0cc299252e2624070db",
+ "rk_hex": "e3769479962198ef9dbcf21e4822b0e2d45f9918f1e7bf86aa6208fc3e3ad3c6",
+ "wraps": [
+ {
+ "name": "W_pw",
+ "object_type": "wrap.pw",
+ "kek_hex": "e70a2f643ee0dc6123d042ca574b44994135d7776016e3d181fc32f93db40d82",
+ "kcv_hex": "fc6587261be8f190edd03ee21eda38b4",
+ "wrap_id_hex": "cc715de83e6bec8114d5478e1b2e13d6",
+ "version": 1,
+ "nonce_hex": "63ca9a59270f1867de916493",
+ "aad_hex": "636f6e7472612f76312f6161640000000010b22c6a5eae1be9d6a773f474fd0532b000000007777261702e707700000010cc715de83e6bec8114d5478e1b2e13d60000000000000001",
+ "ciphertext_hex": "54960d310960a2b99c8df592b1017490c7e89507caef806e00e32ea4263aba34",
+ "tag_hex": "b270200424ec37c3a06158abc13a2c00"
+ },
+ {
+ "name": "W_rc",
+ "object_type": "wrap.rc",
+ "kek_hex": "58c5e4bd32d767d48a63d3f77ed67bad6c1fb4017d90a2f098cb32e8b87ff27b",
+ "kcv_hex": "a4e31d3daede8f7051c4ff6a9cea876b",
+ "wrap_id_hex": "7d4f2401a54f0af0f9c2123e89ce940e",
+ "version": 1,
+ "nonce_hex": "5758cf37dbe4daad674c8ce7",
+ "aad_hex": "636f6e7472612f76312f6161640000000010b22c6a5eae1be9d6a773f474fd0532b000000007777261702e7263000000107d4f2401a54f0af0f9c2123e89ce940e0000000000000001",
+ "ciphertext_hex": "3a0c3081efb8d7c4224e299d7aa4f1ed2e502b10f3b01925de4af09a48c61eed",
+ "tag_hex": "9bb37b5e3d79fd21d91907ca261aee75"
+ },
+ {
+ "name": "W_prf",
+ "object_type": "wrap.prf",
+ "kek_hex": "25a832e73062849b768e245a28a1139f3a0171d41a05a34ce8d2615e7ecacad9",
+ "kcv_hex": "a6a270fb61f6a4dc3c8378d76784c8bc",
+ "wrap_id_hex": "be8b97ce6d27b2fb61229efc81f502b4",
+ "version": 1,
+ "nonce_hex": "2ff8fef97b48f6969b78b23f",
+ "aad_hex": "636f6e7472612f76312f6161640000000010b22c6a5eae1be9d6a773f474fd0532b000000008777261702e70726600000010be8b97ce6d27b2fb61229efc81f502b40000000000000001",
+ "ciphertext_hex": "0b5f5c20153730681bf09e0740b64b7f49f6d3180f1365dd3bead4fffcbe06ea",
+ "tag_hex": "0900826fc6463dbdf94fe5cacbf1c585"
+ }
+ ],
+ "item": {
+ "scope": "vault",
+ "vault_id_hex": "d7331f78b34eb0cc299252e2624070db",
+ "object_type": "item",
+ "item_id_hex": "4580c14ff986936588b14e66e247978d",
+ "version": 1,
+ "k_vault_hex": "576894008c1a94f71704db728d37bfa9f0023cca4fd8d2ae48f6af81a4b959a5",
+ "nonce_hex": "50e4e9c4a5166e560d453550",
+ "aad_hex": "636f6e7472612f76312f6161640000000010d7331f78b34eb0cc299252e2624070db000000046974656d000000104580c14ff986936588b14e66e247978d0000000000000001",
+ "plaintext_utf8": "{\"title\":\"Generated vector item\",\"username\":\"vector@example.invalid\",\"secret\":\"not-a-real-value\"}",
+ "plaintext_hex": "7b227469746c65223a2247656e65726174656420766563746f72206974656d222c22757365726e616d65223a22766563746f72406578616d706c652e696e76616c6964222c22736563726574223a226e6f742d612d7265616c2d76616c7565227d",
+ "ciphertext_hex": "47296118e291596d6ba9d4c6919abb042b01888060a0d6cf13bf224ccf7cc57678945ec6ae628deaff76f21a79463fcc6b3f31a222902be2a23497c86cad77864e40f5cf00db37bacf4580fe0e6f5a969eb4bd8a84fff06ef87b85ca2d9d86200d",
+ "tag_hex": "8c033da48e3a51c5a8e7618c95cba27f"
+ }
+ },
+ "account_chain": {
+ "note": "the whole of DESIGN.md's 'The shape', end to end, so a new implementation can bisect where it diverges",
+ "passphrase_utf8": "contra-golden-vector-passphrase-do-not-use-8f3a1c",
+ "salt_pw_hex": "962c1e2ca2b4c0602e6ec706e76e3eac",
+ "account_key_hex": "f60fb281e8c72f6725add4cbd40e7b08",
+ "recovery_code_hex": "70da5ffc983510bba4672030946b9eed",
+ "prf_output_hex": "6f926e3f71aec7803ffeeef8598e91de4195a42067688c8f4e3fd24c00c50c49",
+ "cred_salt_hex": "ce414df9c26826e10c1c4f359823885eb5eb39f89ead15583cade016d317f276",
+ "s_pw_hex": "6bf83f20da63fb1c818ce0f12e93294ba999b4ad156a7e5ae680f9f38fdd493b",
+ "seed_login_hex": "4f2b905bcff9d01c15bbb82bb0a9d26a1267f35278928d1bd38e8c838ef68b8a",
+ "lk_pw_public_hex": "0465b117b08f92789048944fced0df2343d93b77e8c92448a3f2be170ea91755066a7f789fdf979c17ed782cf9f24cdec565ad8fe752e9f24e974e4c5270cb908f",
+ "seed_rc_login_hex": "46b18c0c2a54c5ef6baeeda49ab42fd24ae8401912020cf94c02a51f1436240b",
+ "lk_rc_public_hex": "048b58c6f080a5fe442a996ad8c7104fc2ecc23fac14d5bfb834fbf949640dae7a4ff41f793d4d7ae97103fe8d5d13faf99b46c52f46a36ce5416d9346b8fac90f",
+ "kek_pw_hex": "e70a2f643ee0dc6123d042ca574b44994135d7776016e3d181fc32f93db40d82",
+ "kek_rc_hex": "58c5e4bd32d767d48a63d3f77ed67bad6c1fb4017d90a2f098cb32e8b87ff27b",
+ "kek_prf_hex": "25a832e73062849b768e245a28a1139f3a0171d41a05a34ce8d2615e7ecacad9",
+ "rk_hex": "e3769479962198ef9dbcf21e4822b0e2d45f9918f1e7bf86aa6208fc3e3ad3c6"
+ }
+}
diff --git a/vectors/gen/Makefile b/vectors/gen/Makefile
new file mode 100644
index 0000000..31ad322
--- /dev/null
+++ b/vectors/gen/Makefile
@@ -0,0 +1,35 @@
+# The golden-vector generator. Not part of the CONTRA binary and not on the
+# Go build path โ€” it exists so the vectors come from an implementation nobody
+# here wrote (OpenSSL) rather than from the Go code they are meant to test.
+#
+# make build both tools
+# make vectors regenerate ../contra-v1.json
+# make search find a fresh rejecting seed (minutes; only needed if the
+# EC derivation label ever changes)
+#
+# REJECT_K is the witness from seedsearch. It is pinned rather than searched
+# on every run because the search costs a few billion HKDF evaluations.
+
+CC ?= cc
+CFLAGS ?= -O2 -Wall -Wextra -std=c99
+LDLIBS := -lcrypto
+REJECT_K ?= 10962785172
+
+all: gen seedsearch
+
+gen: gen.c
+ $(CC) $(CFLAGS) -o $@ $< $(LDLIBS)
+
+seedsearch: seedsearch.c
+ $(CC) $(CFLAGS) -O3 -o $@ $< $(LDLIBS) -lpthread
+
+vectors: gen
+ ./gen $(REJECT_K) > ../contra-v1.json
+
+search: seedsearch
+ ./seedsearch $$(nproc)
+
+clean:
+ rm -f gen seedsearch
+
+.PHONY: all vectors search clean
diff --git a/vectors/gen/gen.c b/vectors/gen/gen.c
new file mode 100644
index 0000000..83b73bf
--- /dev/null
+++ b/vectors/gen/gen.c
@@ -0,0 +1,977 @@
+/*
+ * gen โ€” produce vectors/contra-v1.json from DESIGN.md, using OpenSSL.
+ *
+ * Why this is C and not Go: discussion #6 forbids generating the vectors
+ * from an implementation written in the same session and then testing that
+ * implementation against them, because it proves nothing. So the generator
+ * is a second, independent implementation โ€” OpenSSL 3.x, which nobody here
+ * wrote โ€” and the Go package under test never contributes a byte to this
+ * file.
+ *
+ * Before it generates anything, main() re-computes the published third-party
+ * vectors in `external_anchors` and aborts if any disagrees. That is what
+ * stops "independent" from meaning "differently wrong": the PHC Argon2id
+ * reference outputs, RFC 5869 HKDF and RFC 4231 HMAC all have to come back
+ * right first.
+ *
+ * Every fixture input is itself derived from a published label โ€”
+ *
+ * fixture(label, n) = SHA-256("contra/v1/vectors/" || label)[0..n]
+ *
+ * so the whole file regenerates from nothing but this source, and no value
+ * in it can be mistaken for a real key. AGENTS.md decision 8: fixtures are
+ * generated, never real.
+ *
+ * Build: make Run: ./gen > ../contra-v1.json
+ */
+#include <stdarg.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include <openssl/bn.h>
+#include <openssl/core_names.h>
+#include <openssl/ec.h>
+#include <openssl/evp.h>
+#include <openssl/kdf.h>
+#include <openssl/obj_mac.h>
+#include <openssl/params.h>
+
+/* ---------------------------------------------------------------- basics */
+
+static void die(const char *what) {
+ fprintf(stderr, "gen: %s\n", what);
+ exit(1);
+}
+
+/* Rotating hex buffers, so several hex() calls can share one printf. */
+static const char *hex(const unsigned char *b, size_t n) {
+ static char pool[16][600];
+ static int slot = 0;
+ char *out = pool[slot = (slot + 1) % 16];
+ size_t i;
+
+ if (n * 2 + 1 > sizeof pool[0]) die("hex buffer too small");
+ for (i = 0; i < n; i++) sprintf(out + i * 2, "%02x", b[i]);
+ out[n * 2] = 0;
+ return out;
+}
+
+static void sha256(const unsigned char *m, size_t n, unsigned char out[32]) {
+ unsigned int len = 32;
+ if (!EVP_Digest(m, n, out, &len, EVP_sha256(), NULL)) die("sha256");
+}
+
+static void hmac_sha256(const unsigned char *key, size_t keylen,
+ const unsigned char *msg, size_t msglen,
+ unsigned char out[32]) {
+ EVP_MAC *mac = EVP_MAC_fetch(NULL, "HMAC", NULL);
+ EVP_MAC_CTX *ctx;
+ OSSL_PARAM p[2];
+ size_t outlen = 0;
+
+ if (!mac) die("fetch HMAC");
+ ctx = EVP_MAC_CTX_new(mac);
+ if (!ctx) die("HMAC ctx");
+ p[0] = OSSL_PARAM_construct_utf8_string(OSSL_MAC_PARAM_DIGEST, "SHA256", 0);
+ p[1] = OSSL_PARAM_construct_end();
+ if (!EVP_MAC_init(ctx, key, keylen, p)) die("HMAC init");
+ if (!EVP_MAC_update(ctx, msg, msglen)) die("HMAC update");
+ if (!EVP_MAC_final(ctx, out, &outlen, 32)) die("HMAC final");
+ EVP_MAC_CTX_free(ctx);
+ EVP_MAC_free(mac);
+}
+
+/*
+ * HKDF-SHA-256, RFC 5869 extract-then-expand โ€” the one mode this protocol
+ * uses (discussion #19). A NULL salt is a zero-length salt, which RFC 5869
+ * ยง2.2 turns into HashLen zero bytes.
+ */
+static void hkdf(const unsigned char *ikm, size_t ikmlen,
+ const unsigned char *salt, size_t saltlen,
+ const unsigned char *info, size_t infolen,
+ unsigned char *out, size_t outlen) {
+ EVP_KDF *kdf = EVP_KDF_fetch(NULL, "HKDF", NULL);
+ EVP_KDF_CTX *ctx;
+ OSSL_PARAM p[6];
+ int i = 0;
+ static const unsigned char empty[1] = {0};
+
+ if (!kdf) die("fetch HKDF");
+ ctx = EVP_KDF_CTX_new(kdf);
+ if (!ctx) die("HKDF ctx");
+ p[i++] = OSSL_PARAM_construct_utf8_string(OSSL_KDF_PARAM_DIGEST, "SHA256", 0);
+ p[i++] = OSSL_PARAM_construct_octet_string(
+ OSSL_KDF_PARAM_KEY, (void *)(ikmlen ? ikm : empty), ikmlen);
+ p[i++] = OSSL_PARAM_construct_octet_string(
+ OSSL_KDF_PARAM_SALT, (void *)(saltlen ? salt : empty), saltlen);
+ p[i++] = OSSL_PARAM_construct_octet_string(
+ OSSL_KDF_PARAM_INFO, (void *)(infolen ? info : empty), infolen);
+ p[i] = OSSL_PARAM_construct_end();
+ if (EVP_KDF_derive(ctx, out, outlen, p) <= 0) die("HKDF derive");
+ EVP_KDF_CTX_free(ctx);
+ EVP_KDF_free(kdf);
+}
+
+static void hkdf_label(const unsigned char *ikm, size_t ikmlen,
+ const unsigned char *salt, size_t saltlen,
+ const char *label, unsigned char *out, size_t outlen) {
+ hkdf(ikm, ikmlen, salt, saltlen, (const unsigned char *)label, strlen(label),
+ out, outlen);
+}
+
+static void argon2id(const unsigned char *pass, size_t passlen,
+ const unsigned char *salt, size_t saltlen, uint32_t m_kib,
+ uint32_t t, uint32_t lanes, unsigned char *out,
+ size_t outlen) {
+ EVP_KDF *kdf = EVP_KDF_fetch(NULL, "ARGON2ID", NULL);
+ EVP_KDF_CTX *ctx;
+ OSSL_PARAM p[7];
+ uint32_t threads = 1;
+ int i = 0;
+
+ if (!kdf) die("fetch ARGON2ID (needs OpenSSL 3.2+)");
+ ctx = EVP_KDF_CTX_new(kdf);
+ if (!ctx) die("argon2 ctx");
+ p[i++] = OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_PASSWORD,
+ (void *)pass, passlen);
+ p[i++] = OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_SALT,
+ (void *)salt, saltlen);
+ p[i++] = OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ITER, &t);
+ p[i++] = OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ARGON2_MEMCOST, &m_kib);
+ p[i++] = OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ARGON2_LANES, &lanes);
+ p[i++] = OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_THREADS, &threads);
+ p[i] = OSSL_PARAM_construct_end();
+ if (EVP_KDF_derive(ctx, out, outlen, p) <= 0) die("argon2 derive");
+ EVP_KDF_CTX_free(ctx);
+ EVP_KDF_free(kdf);
+}
+
+/* --------------------------------------------------------------- fixtures */
+
+/*
+ * fixture(label, n) โ€” every synthetic input in the file, derived from its own
+ * published label so the file regenerates from this source alone.
+ */
+static void fixture(const char *label, unsigned char *out, size_t n) {
+ char buf[128];
+ unsigned char d[32];
+
+ if (n > 32) die("fixture longer than SHA-256");
+ if (snprintf(buf, sizeof buf, "contra/v1/vectors/%s", label) >= (int)sizeof buf)
+ die("fixture label too long");
+ sha256((const unsigned char *)buf, strlen(buf), d);
+ memcpy(out, d, n);
+}
+
+/* ------------------------------------------------------ P-256 from a seed */
+
+#define EC_LABEL "contra/v1/ec-p256"
+
+struct p256 {
+ unsigned char cand[8][32]; /* candidates tried, in order */
+ int ncand; /* how many, including the accepted one */
+ unsigned char d[32]; /* the accepted scalar */
+ unsigned char pub[65]; /* SEC1 uncompressed 0x04 || X || Y */
+};
+
+/*
+ * Rejection sampling exactly as pinned in discussion #19: expand the seed
+ * under "contra/v1/ec-p256" || byte(i), reject anything outside [1, n-1],
+ * and step i. Rejection has probability ~2^-32, so `p256_rejecting_seed` in
+ * the vector file is the only reason this loop is ever tested.
+ */
+static void p256_from_seed(const unsigned char seed[32], struct p256 *r) {
+ EC_GROUP *g = EC_GROUP_new_by_curve_name(NID_X9_62_prime256v1);
+ const BIGNUM *n;
+ EC_POINT *pt;
+ BIGNUM *d;
+ unsigned char info[sizeof EC_LABEL]; /* label bytes + counter, no NUL */
+ int i;
+
+ if (!g) die("P-256 group");
+ n = EC_GROUP_get0_order(g);
+ memcpy(info, EC_LABEL, sizeof EC_LABEL - 1);
+ memset(r, 0, sizeof *r);
+
+ for (i = 0; i < 8; i++) {
+ info[sizeof EC_LABEL - 1] = (unsigned char)i;
+ hkdf(seed, 32, NULL, 0, info, sizeof info, r->cand[i], 32);
+ r->ncand = i + 1;
+
+ d = BN_bin2bn(r->cand[i], 32, NULL);
+ if (!d) die("BN_bin2bn");
+ if (!BN_is_zero(d) && BN_cmp(d, n) < 0) {
+ memcpy(r->d, r->cand[i], 32);
+ pt = EC_POINT_new(g);
+ if (!pt || !EC_POINT_mul(g, pt, d, NULL, NULL, NULL))
+ die("EC_POINT_mul");
+ if (EC_POINT_point2oct(g, pt, POINT_CONVERSION_UNCOMPRESSED, r->pub,
+ sizeof r->pub, NULL) != sizeof r->pub)
+ die("point2oct");
+ EC_POINT_free(pt);
+ BN_free(d);
+ EC_GROUP_free(g);
+ return;
+ }
+ BN_free(d);
+ }
+ die("P-256 derivation exhausted 8 candidates");
+}
+
+/* --------------------------------------------------------- AEAD, with AAD */
+
+/*
+ * The canonical AAD, pinned in discussion #19. Length-prefixed so the tuple
+ * is injective โ€” plain concatenation lets different tuples produce identical
+ * AAD, which is the exact transplant the binding exists to stop โ€” and
+ * domain-tagged so it cannot be replayed into another protocol.
+ *
+ * The scope id is the account id for account-scoped objects and the vault id
+ * for vault-scoped ones, which is how DESIGN.md's two spellings of the
+ * binding โ€” "(account_id, object_type, object_id, version)" and "(vault_id,
+ * item_id, version)" โ€” turn out to be the same rule.
+ */
+static size_t build_aad(const unsigned char *scope_id, size_t scope_len,
+ const char *object_type, const unsigned char *object_id,
+ size_t object_id_len, uint64_t version,
+ unsigned char *out, size_t cap) {
+ size_t n = 0, tlen = strlen(object_type);
+ int i;
+
+#define PUT(p, l) \
+ do { \
+ if (n + (l) > cap) die("AAD buffer"); \
+ memcpy(out + n, (p), (l)); \
+ n += (l); \
+ } while (0)
+#define PUT_U32(v) \
+ do { \
+ unsigned char b4[4]; \
+ b4[0] = (unsigned char)((v) >> 24); \
+ b4[1] = (unsigned char)((v) >> 16); \
+ b4[2] = (unsigned char)((v) >> 8); \
+ b4[3] = (unsigned char)(v); \
+ PUT(b4, 4); \
+ } while (0)
+
+ PUT("contra/v1/aad", 13);
+ PUT("\0", 1);
+ PUT_U32((uint32_t)scope_len);
+ PUT(scope_id, scope_len);
+ PUT_U32((uint32_t)tlen);
+ PUT(object_type, tlen);
+ PUT_U32((uint32_t)object_id_len);
+ PUT(object_id, object_id_len);
+ for (i = 7; i >= 0; i--) {
+ unsigned char b = (unsigned char)(version >> (8 * i));
+ PUT(&b, 1);
+ }
+#undef PUT_U32
+#undef PUT
+ return n;
+}
+
+static void aes256gcm(const unsigned char key[32], const unsigned char nonce[12],
+ const unsigned char *aad, size_t aadlen,
+ const unsigned char *pt, size_t ptlen, unsigned char *ct,
+ unsigned char tag[16]) {
+ EVP_CIPHER_CTX *c = EVP_CIPHER_CTX_new();
+ unsigned char scratch[16];
+ int len = 0;
+
+ if (!c) die("gcm ctx");
+ if (!EVP_EncryptInit_ex(c, EVP_aes_256_gcm(), NULL, NULL, NULL))
+ die("gcm init");
+ if (!EVP_CIPHER_CTX_ctrl(c, EVP_CTRL_AEAD_SET_IVLEN, 12, NULL))
+ die("gcm ivlen");
+ if (!EVP_EncryptInit_ex(c, NULL, NULL, key, nonce)) die("gcm key");
+ if (aadlen && !EVP_EncryptUpdate(c, NULL, &len, aad, (int)aadlen))
+ die("gcm aad");
+ if (ptlen && !EVP_EncryptUpdate(c, ct, &len, pt, (int)ptlen))
+ die("gcm update");
+ /* GCM is a stream cipher, so Final never emits bytes; give it somewhere
+ * to write anyway rather than offsetting a possibly-NULL ct. */
+ if (!EVP_EncryptFinal_ex(c, scratch, &len)) die("gcm final");
+ if (!EVP_CIPHER_CTX_ctrl(c, EVP_CTRL_AEAD_GET_TAG, 16, tag)) die("gcm tag");
+ EVP_CIPHER_CTX_free(c);
+}
+
+/* ------------------------------------------------------- Crockford Base32 */
+
+static const char CROCKFORD[33] = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
+
+/*
+ * Byte-oriented, MSB-first, left-aligned (discussion #19) โ€” 16 bytes become
+ * 26 symbols with two zero pad bits in the last one. Byte packing rather than
+ * Crockford's big-integer form because the JS console has a 300-line module
+ * cap and no business carrying a bignum.
+ */
+static void crockford_encode(const unsigned char *in, size_t nbytes, char *out) {
+ size_t nsym = (nbytes * 8 + 4) / 5, i;
+
+ for (i = 0; i < nsym; i++) {
+ size_t bit = i * 5, byte = bit / 8, off = bit % 8;
+ unsigned v = (unsigned)in[byte] << 8;
+ if (byte + 1 < nbytes) v |= in[byte + 1];
+ out[i] = CROCKFORD[(v >> (11 - off)) & 0x1f];
+ }
+ out[nsym] = 0;
+}
+
+/* The 20 bits of SHA-256(payload) that become the 4 checksum symbols. */
+static uint32_t checksum20(const unsigned char *payload, size_t n) {
+ unsigned char d[32];
+ sha256(payload, n, d);
+ return ((uint32_t)d[0] << 12) | ((uint32_t)d[1] << 4) | (uint32_t)(d[2] >> 4);
+}
+
+static void checksum_symbols(uint32_t c20, char out[5]) {
+ int i;
+ for (i = 0; i < 4; i++) out[i] = CROCKFORD[(c20 >> (15 - 5 * i)) & 0x1f];
+ out[4] = 0;
+}
+
+static int crockford_value(char c) {
+ const char *p;
+ if (c >= 'a' && c <= 'z') c = (char)(c - 'a' + 'A');
+ if (c == 'O') c = '0';
+ if (c == 'I' || c == 'L') c = '1';
+ p = strchr(CROCKFORD, c);
+ return (p && c) ? (int)(p - CROCKFORD) : -1;
+}
+
+/*
+ * Normalisation: strip hyphens and whitespace, uppercase, O->0, I->1, L->1.
+ * The tag survives it unchanged, so normalise first and match the tag after.
+ * Returns 0 if any character is outside the alphabet โ€” `U` is the one people
+ * ask about, and it is an error, not a repair candidate.
+ */
+static int crockford_normalize(const char *in, char *out, size_t cap) {
+ size_t n = 0;
+
+ for (; *in; in++) {
+ if (*in == '-' || *in == ' ' || *in == '\t' || *in == '\n') continue;
+ if (crockford_value(*in) < 0) return 0;
+ if (n + 1 >= cap) return 0;
+ out[n++] = CROCKFORD[crockford_value(*in)];
+ }
+ out[n] = 0;
+ return 1;
+}
+
+/*
+ * Strict decode of 26 symbols back to 16 bytes. A non-zero pad in the last
+ * symbol is rejected, so every value has exactly one encoding โ€” without that
+ * two strings decode to one key and the checksum stops meaning what it says.
+ */
+static int crockford_decode16(const char *sym, unsigned char out[16]) {
+ unsigned acc = 0;
+ int nbits = 0, i, o = 0;
+
+ for (i = 0; i < 26; i++) {
+ int v = crockford_value(sym[i]);
+ if (v < 0) return 0;
+ acc = (acc << 5) | (unsigned)v;
+ nbits += 5;
+ if (nbits >= 8) {
+ nbits -= 8;
+ if (o >= 16) return 0;
+ out[o++] = (unsigned char)((acc >> nbits) & 0xff);
+ }
+ }
+ return o == 16 && (acc & ((1u << nbits) - 1)) == 0;
+}
+
+/* tag(2) || data(26) || checksum(4), grouped five at a time for printing. */
+static void printed_form(const char *tag, const unsigned char payload[16],
+ char normalized[33], char printed[40]) {
+ char data[27], ck[5];
+ int i, o = 0;
+
+ crockford_encode(payload, 16, data);
+ checksum_symbols(checksum20(payload, 16), ck);
+ snprintf(normalized, 33, "%s%s%s", tag, data, ck);
+
+ memcpy(printed, tag, 2);
+ o = 2;
+ for (i = 0; i < 30; i++) {
+ if (i % 5 == 0) printed[o++] = '-';
+ printed[o++] = normalized[2 + i];
+ }
+ printed[o] = 0;
+}
+
+/* A normalised 32-character code is valid when it decodes and its own
+ * checksum symbols match. Nothing here looks at the tag. */
+static int code_valid(const char *norm, unsigned char payload[16]) {
+ char want[5];
+ if (strlen(norm) != 32) return 0;
+ if (!crockford_decode16(norm + 2, payload)) return 0;
+ checksum_symbols(checksum20(payload, 16), want);
+ return memcmp(norm + 28, want, 4) == 0;
+}
+
+/*
+ * Repair: try every single-character substitution over the 30 payload
+ * positions and return every survivor, in position-then-alphabet order. It
+ * deliberately does not pick one โ€” a 20-bit checksum collides about once in
+ * 1100 attempts, and silently choosing would be worse than handing the caller
+ * two candidates to try against the KCV.
+ */
+static int code_repair(const char *norm, char out[][33], int cap) {
+ int n = 0, pos, s;
+
+ for (pos = 2; pos < 32; pos++) {
+ for (s = 0; s < 32; s++) {
+ char cand[33];
+ unsigned char payload[16];
+ if (CROCKFORD[s] == norm[pos]) continue;
+ memcpy(cand, norm, 33);
+ cand[pos] = CROCKFORD[s];
+ if (!code_valid(cand, payload)) continue;
+ if (n < cap) memcpy(out[n], cand, 33);
+ n++;
+ }
+ }
+ return n;
+}
+
+/* -------------------------------------------------------- JSON emit sugar */
+
+static int need_comma = 0;
+static const char *indent = " ";
+
+static void field(const char *k, const char *fmt, ...) {
+ va_list ap;
+ if (need_comma) printf(",\n");
+ printf("%s\"%s\": ", indent, k);
+ va_start(ap, fmt);
+ vprintf(fmt, ap);
+ va_end(ap);
+ need_comma = 1;
+}
+
+/* ------------------------------------------------- external anchor checks */
+
+static void expect(const char *what, const unsigned char *got, size_t n,
+ const char *want_hex) {
+ if (strcmp(hex(got, n), want_hex) != 0) {
+ fprintf(stderr, "gen: anchor %s mismatch\n got %s\n want %s\n", what,
+ hex(got, n), want_hex);
+ exit(1);
+ }
+}
+
+/*
+ * Check OpenSSL against published third-party vectors before it is allowed to
+ * generate ours. Without this, "independent implementation" only guarantees
+ * the two implementations are not the same โ€” not that either is right.
+ */
+static void check_anchors(void) {
+ unsigned char out[42];
+ static const unsigned char rfc5869_ikm[22] = {
+ 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b,
+ 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b};
+ static const unsigned char rfc5869_salt[13] = {0x00, 0x01, 0x02, 0x03, 0x04,
+ 0x05, 0x06, 0x07, 0x08, 0x09,
+ 0x0a, 0x0b, 0x0c};
+ static const unsigned char rfc5869_info[10] = {0xf0, 0xf1, 0xf2, 0xf3, 0xf4,
+ 0xf5, 0xf6, 0xf7, 0xf8, 0xf9};
+ static const unsigned char rfc4231_key[20] = {
+ 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b,
+ 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b};
+
+ /* PHC argon2 reference, Argon2id v=0x13, t=2 m=65536 p=1. */
+ argon2id((const unsigned char *)"password", 8,
+ (const unsigned char *)"somesalt", 8, 65536, 2, 1, out, 32);
+ expect("PHC Argon2id t=2 m=65536 p=1", out, 32,
+ "09316115d5cf24ed5a15a31a3ba326e5cf32edc24702987c02b6566f61913cf7");
+
+ /* Same, at t=3 โ€” the parameters DESIGN.md actually pins. */
+ argon2id((const unsigned char *)"password", 8,
+ (const unsigned char *)"somesalt", 8, 65536, 3, 1, out, 32);
+
+ /* RFC 5869 test case 1. */
+ hkdf(rfc5869_ikm, sizeof rfc5869_ikm, rfc5869_salt, sizeof rfc5869_salt,
+ rfc5869_info, sizeof rfc5869_info, out, 42);
+ expect("RFC 5869 case 1", out, 42,
+ "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf"
+ "34007208d5b887185865");
+
+ /* RFC 5869 test case 3: zero-length salt and info โ€” the shape every
+ * unsalted CONTRA label uses. */
+ hkdf(rfc5869_ikm, sizeof rfc5869_ikm, NULL, 0, NULL, 0, out, 42);
+ expect("RFC 5869 case 3", out, 42,
+ "8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d"
+ "9d201395faa4b61a96c8");
+
+ /* RFC 4231 test case 1, HMAC-SHA-256. */
+ hmac_sha256(rfc4231_key, sizeof rfc4231_key, (const unsigned char *)"Hi There",
+ 8, out);
+ expect("RFC 4231 case 1", out, 32,
+ "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7");
+
+ /* NIST CAVP AES-256-GCM, all-zero key/IV, empty plaintext and AAD. */
+ {
+ unsigned char key[32] = {0}, iv[12] = {0}, tag[16];
+ aes256gcm(key, iv, NULL, 0, NULL, 0, NULL, tag);
+ expect("NIST AES-256-GCM empty", tag, 16,
+ "530f8afbc74536b9a963b4f1c4cb738b");
+ }
+}
+
+/* ------------------------------------------------------------------- main */
+
+struct wrap {
+ const char *name;
+ const char *object_type;
+ unsigned char kek[32];
+ unsigned char kcv[16];
+ unsigned char wrap_id[16];
+ unsigned char nonce[12];
+ unsigned char aad[256];
+ size_t aadlen;
+ unsigned char ct[32];
+ unsigned char tag[16];
+};
+
+/* KCV = HMAC-SHA-256(KEK, "contra/v1/kcv")[..16] โ€” AES-GCM is not
+ * key-committing, so this is the commitment check, and it is also what tells
+ * "wrong key" from "corrupted blob" on every unlock path. */
+static void kcv_of(const unsigned char kek[32], unsigned char out[16]) {
+ unsigned char full[32];
+ hmac_sha256(kek, 32, (const unsigned char *)"contra/v1/kcv", 13, full);
+ memcpy(out, full, 16);
+}
+
+static void seal_wrap(struct wrap *w, const unsigned char account_id[16],
+ const unsigned char rk[32]) {
+ kcv_of(w->kek, w->kcv);
+ w->aadlen = build_aad(account_id, 16, w->object_type, w->wrap_id, 16, 1,
+ w->aad, sizeof w->aad);
+ aes256gcm(w->kek, w->nonce, w->aad, w->aadlen, rk, 32, w->ct, w->tag);
+}
+
+static void emit_wrap(const struct wrap *w) {
+ printf(" {\n");
+ need_comma = 0;
+ field("name", "\"%s\"", w->name);
+ field("object_type", "\"%s\"", w->object_type);
+ field("kek_hex", "\"%s\"", hex(w->kek, 32));
+ field("kcv_hex", "\"%s\"", hex(w->kcv, 16));
+ field("wrap_id_hex", "\"%s\"", hex(w->wrap_id, 16));
+ field("version", "1");
+ field("nonce_hex", "\"%s\"", hex(w->nonce, 12));
+ field("aad_hex", "\"%s\"", hex(w->aad, w->aadlen));
+ field("ciphertext_hex", "\"%s\"", hex(w->ct, 32));
+ field("tag_hex", "\"%s\"", hex(w->tag, 16));
+ printf("\n }");
+}
+
+int main(int argc, char **argv) {
+ /* The witness k from seedsearch, passed in by the Makefile. */
+ unsigned long long reject_k = (argc > 1) ? strtoull(argv[1], NULL, 10) : 0;
+
+ const char *passphrase = "contra-golden-vector-passphrase-do-not-use-8f3a1c";
+ unsigned char salt_pw[16], account_key[16], recovery_code[16];
+ unsigned char prf_output[32], cred_salt[32], rk[32], k_vault[32];
+ unsigned char account_id[16], vault_id[16], item_id[16];
+ unsigned char s_pw[32], seed_login[32], seed_rc[32];
+ unsigned char item_nonce[12], item_ct[256], item_tag[16], item_aad[256];
+ unsigned char reject_seed[32];
+ size_t item_aadlen, item_ptlen;
+ struct p256 lk_pw, lk_rc, lk_reject;
+ struct wrap w_pw, w_rc, w_prf;
+ char ak_norm[33], ak_print[40], rc_norm[33], rc_print[40];
+ char typo[40];
+ const char *item_pt =
+ "{\"title\":\"Generated vector item\",\"username\":\"vector@example."
+ "invalid\",\"secret\":\"not-a-real-value\"}";
+ int i;
+
+ check_anchors();
+
+ fixture("salt_pw", salt_pw, 16);
+ fixture("account_key", account_key, 16);
+ fixture("recovery_code", recovery_code, 16);
+ fixture("prf_output", prf_output, 32);
+ fixture("cred_salt", cred_salt, 32);
+ fixture("rk", rk, 32);
+ fixture("k_vault", k_vault, 32);
+ fixture("account_id", account_id, 16);
+ fixture("vault_id", vault_id, 16);
+ fixture("item_id", item_id, 16);
+ fixture("nonce/item", item_nonce, 12);
+
+ /* The passphrase branch. One expensive Argon2id call, then two peer
+ * subkeys under distinct labels โ€” holding one says nothing about the
+ * other without inverting HMAC-SHA-256. */
+ argon2id((const unsigned char *)passphrase, strlen(passphrase), salt_pw, 16,
+ 65536, 3, 1, s_pw, 32);
+ hkdf_label(s_pw, 32, NULL, 0, "contra/v1/login", seed_login, 32);
+ p256_from_seed(seed_login, &lk_pw);
+
+ w_pw.name = "W_pw";
+ w_pw.object_type = "wrap.pw";
+ hkdf_label(s_pw, 32, account_key, 16, "contra/v1/kek", w_pw.kek, 32);
+ fixture("wrap_id/pw", w_pw.wrap_id, 16);
+ fixture("nonce/wrap_pw", w_pw.nonce, 12);
+ seal_wrap(&w_pw, account_id, rk);
+
+ /* The recovery branch. HKDF, not Argon2id: the input is already
+ * full-entropy, and this path must be the least-rotted code we own. */
+ hkdf_label(recovery_code, 16, NULL, 0, "contra/v1/rc-login", seed_rc, 32);
+ p256_from_seed(seed_rc, &lk_rc);
+
+ w_rc.name = "W_rc";
+ w_rc.object_type = "wrap.rc";
+ hkdf_label(recovery_code, 16, NULL, 0, "contra/v1/rc-kek", w_rc.kek, 32);
+ fixture("wrap_id/rc", w_rc.wrap_id, 16);
+ fixture("nonce/wrap_rc", w_rc.nonce, 12);
+ seal_wrap(&w_rc, account_id, rk);
+
+ /* The passkey branch โ€” a third peer wrap, never a shortcut around RK. */
+ w_prf.name = "W_prf";
+ w_prf.object_type = "wrap.prf";
+ hkdf_label(prf_output, 32, cred_salt, 32, "contra/v1/prf-kek", w_prf.kek, 32);
+ fixture("wrap_id/prf", w_prf.wrap_id, 16);
+ fixture("nonce/wrap_prf", w_prf.nonce, 12);
+ seal_wrap(&w_prf, account_id, rk);
+
+ /* An item, sealed under the vault key and scoped to the vault. */
+ item_ptlen = strlen(item_pt);
+ item_aadlen = build_aad(vault_id, 16, "item", item_id, 16, 1, item_aad,
+ sizeof item_aad);
+ aes256gcm(k_vault, item_nonce, item_aad, item_aadlen,
+ (const unsigned char *)item_pt, item_ptlen, item_ct, item_tag);
+
+ /* The seed that forces the rejection branch. */
+ memcpy(reject_seed, "contra/v1/vector-search!", 24);
+ for (i = 0; i < 8; i++)
+ reject_seed[24 + i] = (unsigned char)(reject_k >> (56 - 8 * i));
+ p256_from_seed(reject_seed, &lk_reject);
+ if (reject_k && lk_reject.ncand < 2)
+ die("witness k does not reject at i=0 โ€” rerun seedsearch");
+
+ printed_form("A1", account_key, ak_norm, ak_print);
+ printed_form("C1", recovery_code, rc_norm, rc_print);
+
+ /* ------------------------------------------------------------ output */
+
+ printf("{\n");
+ printf(" \"format\": \"contra-golden-vectors\",\n");
+ printf(" \"version\": 1,\n");
+ printf(" \"spec\": \"DESIGN.md; wire formats pinned in discussion 19\",\n");
+ printf(" \"generator\": \"vectors/gen/gen.c against %s\",\n",
+ OPENSSL_VERSION_TEXT);
+ printf(" \"note\": \"Every input here is generated: fixture(label) = "
+ "SHA-256(\\\"contra/v1/vectors/\\\" || label). Nothing in this file "
+ "is a real secret, and nothing in it may be reused as one.\",\n");
+ printf(" \"binding\": \"The Go, JS and Swift crypto suites must all pass "
+ "this file. Changing a value here is changing the protocol.\",\n");
+
+ printf(" \"external_anchors\": {\n");
+ printf(" \"note\": \"Published third-party vectors. gen.c recomputes "
+ "each one and aborts on mismatch before generating anything "
+ "below.\",\n");
+ printf(" \"sources\": [\"PHC argon2 reference vectors\", \"RFC 5869 "
+ "cases 1 and 3\", \"RFC 4231 case 1\", \"NIST CAVP AES-256-GCM\"]\n");
+ printf(" },\n");
+
+ printf(" \"argon2id\": {\n");
+ printf(" \"params\": {\"m_kib\": 65536, \"t\": 3, \"p\": 1, "
+ "\"tag_len\": 32},\n");
+ printf(" \"floor\": {\"m_kib\": 65536, \"t\": 3, \"note\": \"clients "
+ "refuse anything weaker a server offers\"},\n");
+ printf(" \"cases\": [\n");
+ printf(" {\n");
+ need_comma = 0;
+ field("name", "\"S_pw\"");
+ field("passphrase_utf8", "\"%s\"", passphrase);
+ field("passphrase_hex", "\"%s\"",
+ hex((const unsigned char *)passphrase, strlen(passphrase)));
+ field("salt_hex", "\"%s\"", hex(salt_pw, 16));
+ field("out_hex", "\"%s\"", hex(s_pw, 32));
+ printf("\n }\n ]\n },\n");
+
+ printf(" \"hkdf\": {\n");
+ printf(" \"construction\": \"RFC 5869 extract-then-expand, SHA-256; an "
+ "absent salt is zero-length; info is the label's ASCII bytes with "
+ "no NUL\",\n");
+ printf(" \"cases\": [\n");
+ {
+ struct {
+ const char *name, *label;
+ const unsigned char *ikm;
+ size_t ikmlen;
+ const unsigned char *salt;
+ size_t saltlen;
+ const unsigned char *out;
+ } cases[] = {
+ {"seed_login", "contra/v1/login", s_pw, 32, NULL, 0, seed_login},
+ {"KEK_pw", "contra/v1/kek", s_pw, 32, account_key, 16, w_pw.kek},
+ {"seed_rc_login", "contra/v1/rc-login", recovery_code, 16, NULL, 0,
+ seed_rc},
+ {"KEK_rc", "contra/v1/rc-kek", recovery_code, 16, NULL, 0, w_rc.kek},
+ {"KEK_prf", "contra/v1/prf-kek", prf_output, 32, cred_salt, 32,
+ w_prf.kek},
+ };
+ for (i = 0; i < (int)(sizeof cases / sizeof cases[0]); i++) {
+ printf(" {\n");
+ need_comma = 0;
+ field("name", "\"%s\"", cases[i].name);
+ field("info", "\"%s\"", cases[i].label);
+ field("ikm_hex", "\"%s\"", hex(cases[i].ikm, cases[i].ikmlen));
+ field("salt_hex", "\"%s\"",
+ cases[i].saltlen ? hex(cases[i].salt, cases[i].saltlen) : "");
+ field("l", "32");
+ field("okm_hex", "\"%s\"", hex(cases[i].out, 32));
+ printf("\n }%s\n",
+ i + 1 < (int)(sizeof cases / sizeof cases[0]) ? "," : "");
+ }
+ }
+ printf(" ]\n },\n");
+
+ printf(" \"kcv\": {\n");
+ printf(" \"construction\": \"HMAC-SHA-256(KEK, \\\"contra/v1/kcv\\\")"
+ "[..16]\",\n");
+ printf(" \"cases\": [\n");
+ printf(" {\"name\": \"KCV_pw\", \"kek_hex\": \"%s\", \"kcv_hex\": "
+ "\"%s\"},\n",
+ hex(w_pw.kek, 32), hex(w_pw.kcv, 16));
+ printf(" {\"name\": \"KCV_rc\", \"kek_hex\": \"%s\", \"kcv_hex\": "
+ "\"%s\"},\n",
+ hex(w_rc.kek, 32), hex(w_rc.kcv, 16));
+ printf(" {\"name\": \"KCV_prf\", \"kek_hex\": \"%s\", \"kcv_hex\": "
+ "\"%s\"}\n",
+ hex(w_prf.kek, 32), hex(w_prf.kcv, 16));
+ printf(" ]\n },\n");
+
+ printf(" \"p256_from_seed\": {\n");
+ printf(" \"construction\": \"candidate_i = HKDF-SHA-256(ikm=seed, "
+ "salt=\\\"\\\", info=\\\"%s\\\" || byte(i)); accept when 1 <= "
+ "OS2IP(candidate) <= n-1, else i += 1\",\n",
+ EC_LABEL);
+ printf(" \"public_key_encoding\": \"SEC1 uncompressed, 0x04 || X || "
+ "Y\",\n");
+ printf(" \"cases\": [\n");
+ {
+ struct {
+ const char *name;
+ const unsigned char *seed;
+ const struct p256 *r;
+ } cases[] = {
+ {"LK_pw", seed_login, &lk_pw},
+ {"LK_rc", seed_rc, &lk_rc},
+ {"rejects_at_i0", reject_seed, &lk_reject},
+ };
+ int c;
+ for (c = 0; c < 3; c++) {
+ printf(" {\n");
+ need_comma = 0;
+ field("name", "\"%s\"", cases[c].name);
+ field("seed_hex", "\"%s\"", hex(cases[c].seed, 32));
+ field("candidates_tried", "%d", cases[c].r->ncand);
+ printf(",\n \"candidates\": [");
+ for (i = 0; i < cases[c].r->ncand; i++)
+ printf("%s\"%s\"", i ? ", " : "", hex(cases[c].r->cand[i], 32));
+ printf("]");
+ need_comma = 1;
+ field("d_hex", "\"%s\"", hex(cases[c].r->d, 32));
+ field("public_uncompressed_hex", "\"%s\"", hex(cases[c].r->pub, 65));
+ printf("\n }%s\n", c < 2 ? "," : "");
+ }
+ }
+ printf(" ]\n },\n");
+ printf(" \"p256_rejecting_seed\": {\n");
+ printf(" \"note\": \"A rejection has probability about 2^-32, so this "
+ "seed was searched for. Without it the rejection branch is dead "
+ "code in all three languages.\",\n");
+ printf(" \"seed_recipe\": \"\\\"contra/v1/vector-search!\\\" || "
+ "u64be(k)\",\n");
+ printf(" \"k\": %llu,\n", reject_k);
+ printf(" \"seed_hex\": \"%s\"\n", hex(reject_seed, 32));
+ printf(" },\n");
+
+ printf(" \"crockford\": {\n");
+ printf(" \"alphabet\": \"%s\",\n", CROCKFORD);
+ printf(" \"packing\": \"MSB-first over the raw bytes, left-aligned; 16 "
+ "bytes -> 26 symbols with two zero pad bits. Decoding rejects a "
+ "non-zero pad, so every value has exactly one encoding.\",\n");
+ printf(" \"normalize\": \"strip '-' and whitespace, uppercase, O->0, "
+ "I->1, L->1; U is not in the alphabet and is an error\",\n");
+ printf(" \"encode_cases\": [\n");
+ {
+ unsigned char z[16], f[16];
+ char s[27];
+ memset(z, 0x00, 16);
+ memset(f, 0xff, 16);
+ crockford_encode(z, 16, s);
+ printf(" {\"bytes_hex\": \"%s\", \"symbols\": \"%s\"},\n",
+ hex(z, 16), s);
+ crockford_encode(f, 16, s);
+ printf(" {\"bytes_hex\": \"%s\", \"symbols\": \"%s\"},\n",
+ hex(f, 16), s);
+ crockford_encode(account_key, 16, s);
+ printf(" {\"bytes_hex\": \"%s\", \"symbols\": \"%s\"},\n",
+ hex(account_key, 16), s);
+ crockford_encode(recovery_code, 16, s);
+ printf(" {\"bytes_hex\": \"%s\", \"symbols\": \"%s\"}\n",
+ hex(recovery_code, 16), s);
+ }
+ printf(" ],\n");
+ printf(" \"normalize_cases\": [\n");
+ {
+ static const char *inputs[] = {"c1-oi1l-0O1I", " A1 oL Li ",
+ "c1-abc-def", "C1-UUUU", "c1?abc"};
+ char norm[64];
+ int k;
+ for (k = 0; k < 5; k++) {
+ int ok = crockford_normalize(inputs[k], norm, sizeof norm);
+ printf(" {\"input\": \"%s\", ", inputs[k]);
+ if (ok)
+ printf("\"normalized\": \"%s\"}", norm);
+ else
+ printf("\"error\": \"character outside the Crockford "
+ "alphabet\"}");
+ printf("%s\n", k < 4 ? "," : "");
+ }
+ }
+ printf(" ],\n");
+ printf(" \"decode_cases\": [\n");
+ {
+ char sym[27];
+ unsigned char rt[16];
+ char bad[27];
+ crockford_encode(recovery_code, 16, sym);
+ printf(" {\"symbols\": \"%s\", \"bytes_hex\": \"%s\"},\n", sym,
+ crockford_decode16(sym, rt) ? hex(rt, 16) : "DECODE FAILED");
+ /* Same value, last symbol nudged so the two pad bits are no longer
+ * zero. Strict decoding must refuse it. */
+ memcpy(bad, sym, sizeof bad);
+ bad[25] = CROCKFORD[(crockford_value(bad[25]) + 1) & 0x1f];
+ printf(" {\"symbols\": \"%s\", \"error\": \"non-zero pad bits in "
+ "the final symbol\", \"rejected\": %s}\n",
+ bad, crockford_decode16(bad, rt) ? "false" : "true");
+ }
+ printf(" ]\n },\n");
+
+ printf(" \"printed_code\": {\n");
+ printf(" \"form\": \"tag(2) || data(26) || checksum(4), printed as "
+ "TAG-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX\",\n");
+ printf(" \"checksum\": \"first 20 bits, MSB-first, of "
+ "SHA-256(payload_bytes) -- over the 16 raw bytes, not the "
+ "string\",\n");
+ printf(" \"cases\": [\n");
+ printf(" {\"name\": \"account_key\", \"tag\": \"A1\", "
+ "\"payload_hex\": \"%s\", \"checksum20\": %u, \"normalized\": "
+ "\"%s\", \"printed\": \"%s\"},\n",
+ hex(account_key, 16), checksum20(account_key, 16), ak_norm, ak_print);
+ printf(" {\"name\": \"recovery_code\", \"tag\": \"C1\", "
+ "\"payload_hex\": \"%s\", \"checksum20\": %u, \"normalized\": "
+ "\"%s\", \"printed\": \"%s\"}\n",
+ hex(recovery_code, 16), checksum20(recovery_code, 16), rc_norm,
+ rc_print);
+ printf(" ],\n");
+
+ /* One substituted character, for the self-healing repair path. The
+ * candidate list is what a client tries against the KCV; the repair
+ * never picks one on its own, because 20 bits collides. */
+ {
+ int pos = 7; /* a data position, well inside the printed groups */
+ char cands[8][33];
+ unsigned char payload[16];
+ int ncand, k;
+
+ strcpy(typo, rc_norm);
+ typo[pos] = CROCKFORD[(crockford_value(typo[pos]) + 9) & 0x1f];
+ if (code_valid(typo, payload)) die("typo case still validates");
+ ncand = code_repair(typo, cands, 8);
+
+ printf(" \"repair\": {\n");
+ printf(" \"note\": \"substitute one character, then brute-force "
+ "all 30 payload positions x 32 symbols against the checksum; "
+ "return every survivor in position-then-alphabet order for the "
+ "caller to try against the KCV\",\n");
+ printf(" \"correct_normalized\": \"%s\",\n", rc_norm);
+ printf(" \"typo_normalized\": \"%s\",\n", typo);
+ printf(" \"typo_position\": %d,\n", pos);
+ printf(" \"candidates\": [");
+ for (k = 0; k < ncand && k < 8; k++)
+ printf("%s\"%s\"", k ? ", " : "", cands[k]);
+ printf("],\n");
+ printf(" \"candidate_count\": %d,\n", ncand);
+ {
+ int found = 0;
+ for (k = 0; k < ncand && k < 8; k++)
+ if (strcmp(cands[k], rc_norm) == 0) found = 1;
+ if (!found) die("repair lost the correct code");
+ printf(" \"correct_is_a_candidate\": true\n");
+ }
+ printf(" }\n },\n");
+ }
+
+ printf(" \"aead\": {\n");
+ printf(" \"algorithm\": \"AES-256-GCM, 96-bit nonce, 128-bit tag\",\n");
+ printf(" \"aad\": \"\\\"contra/v1/aad\\\" || 0x00 || "
+ "u32be(len(scope_id)) || scope_id || u32be(len(object_type)) || "
+ "object_type || u32be(len(object_id)) || object_id || "
+ "u64be(version)\",\n");
+ printf(" \"scope_id\": \"the account id for account-scoped objects, the "
+ "vault id for vault-scoped ones\",\n");
+ printf(" \"account_id_hex\": \"%s\",\n", hex(account_id, 16));
+ printf(" \"vault_id_hex\": \"%s\",\n", hex(vault_id, 16));
+ printf(" \"rk_hex\": \"%s\",\n", hex(rk, 32));
+ printf(" \"wraps\": [\n");
+ emit_wrap(&w_pw);
+ printf(",\n");
+ emit_wrap(&w_rc);
+ printf(",\n");
+ emit_wrap(&w_prf);
+ printf("\n ],\n");
+ printf(" \"item\": {\n");
+ need_comma = 0;
+ field("scope", "\"vault\"");
+ field("vault_id_hex", "\"%s\"", hex(vault_id, 16));
+ field("object_type", "\"item\"");
+ field("item_id_hex", "\"%s\"", hex(item_id, 16));
+ field("version", "1");
+ field("k_vault_hex", "\"%s\"", hex(k_vault, 32));
+ field("nonce_hex", "\"%s\"", hex(item_nonce, 12));
+ field("aad_hex", "\"%s\"", hex(item_aad, item_aadlen));
+ field("plaintext_utf8", "\"%s\"",
+ "{\\\"title\\\":\\\"Generated vector item\\\",\\\"username\\\":"
+ "\\\"vector@example.invalid\\\",\\\"secret\\\":"
+ "\\\"not-a-real-value\\\"}");
+ field("plaintext_hex", "\"%s\"",
+ hex((const unsigned char *)item_pt, item_ptlen));
+ field("ciphertext_hex", "\"%s\"", hex(item_ct, item_ptlen));
+ field("tag_hex", "\"%s\"", hex(item_tag, 16));
+ printf("\n }\n },\n");
+
+ printf(" \"account_chain\": {\n");
+ printf(" \"note\": \"the whole of DESIGN.md's 'The shape', end to end, "
+ "so a new implementation can bisect where it diverges\",\n");
+ need_comma = 0;
+ indent = " ";
+ field("passphrase_utf8", "\"%s\"", passphrase);
+ field("salt_pw_hex", "\"%s\"", hex(salt_pw, 16));
+ field("account_key_hex", "\"%s\"", hex(account_key, 16));
+ field("recovery_code_hex", "\"%s\"", hex(recovery_code, 16));
+ field("prf_output_hex", "\"%s\"", hex(prf_output, 32));
+ field("cred_salt_hex", "\"%s\"", hex(cred_salt, 32));
+ field("s_pw_hex", "\"%s\"", hex(s_pw, 32));
+ field("seed_login_hex", "\"%s\"", hex(seed_login, 32));
+ field("lk_pw_public_hex", "\"%s\"", hex(lk_pw.pub, 65));
+ field("seed_rc_login_hex", "\"%s\"", hex(seed_rc, 32));
+ field("lk_rc_public_hex", "\"%s\"", hex(lk_rc.pub, 65));
+ field("kek_pw_hex", "\"%s\"", hex(w_pw.kek, 32));
+ field("kek_rc_hex", "\"%s\"", hex(w_rc.kek, 32));
+ field("kek_prf_hex", "\"%s\"", hex(w_prf.kek, 32));
+ field("rk_hex", "\"%s\"", hex(rk, 32));
+ printf("\n }\n}\n");
+
+ return 0;
+}
diff --git a/vectors/gen/seedsearch.c b/vectors/gen/seedsearch.c
new file mode 100644
index 0000000..d476a88
--- /dev/null
+++ b/vectors/gen/seedsearch.c
@@ -0,0 +1,166 @@
+/*
+ * seedsearch โ€” find a seed whose first P-256 candidate is out of range.
+ *
+ * DESIGN.md requires rejection sampling mod n when deriving a P-256 scalar
+ * from a seed, and discussion #6 requires a vector that actually exercises
+ * the rejection branch: "pin a seed that requires a rejection, or the branch
+ * goes untested". A rejection happens with probability about 2^-32, so no
+ * seed anybody picks by hand will ever hit it. This searches for one.
+ *
+ * The search space is deliberately reproducible rather than random:
+ *
+ * seed_k = "contra/v1/vector-search!" || u64be(k) (32 bytes)
+ *
+ * so the witness is a single number: the vector file pins k, and anyone can
+ * rebuild the seed from it without trusting this program. It also makes the
+ * seed visibly generated rather than plausibly a real key. (Threads race, so
+ * the k reported is a witness, not necessarily the smallest one.)
+ *
+ * A hit is a k where OS2IP(candidate_0) is 0 or >= n, with
+ *
+ * candidate_0 = HKDF-SHA-256(IKM = seed_k, salt = "",
+ * info = "contra/v1/ec-p256" || 0x00)
+ *
+ * This tool is a helper, not an authority. Whatever it finds is re-derived
+ * and re-checked by gen.c against OpenSSL before it reaches the vector file.
+ *
+ * Build: make seedsearch Run: ./seedsearch [threads]
+ */
+#define OPENSSL_SUPPRESS_DEPRECATED 1
+
+#include <inttypes.h>
+#include <pthread.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include <openssl/sha.h>
+
+#define SEED_PREFIX "contra/v1/vector-search!" /* 24 bytes, no NUL */
+#define SEED_PREFIX_LEN 24
+#define EC_LABEL "contra/v1/ec-p256"
+#define EC_LABEL_LEN 17
+
+/* P-256 group order n, big-endian. */
+static const unsigned char P256_N[32] = {
+ 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff,
+ 0xff, 0xff, 0xff, 0xff, 0xff, 0xbc, 0xe6, 0xfa, 0xad, 0xa7, 0x17,
+ 0x9e, 0x84, 0xf3, 0xb9, 0xca, 0xc2, 0xfc, 0x63, 0x25, 0x51};
+
+/* HMAC-SHA-256 over a key of at most 64 bytes. Enough for this search. */
+static void hmac_sha256(const unsigned char *key, size_t keylen,
+ const unsigned char *msg, size_t msglen,
+ unsigned char out[32]) {
+ unsigned char ipad[64], opad[64], inner[32];
+ SHA256_CTX c;
+ size_t i;
+
+ memset(ipad, 0x36, sizeof ipad);
+ memset(opad, 0x5c, sizeof opad);
+ for (i = 0; i < keylen; i++) {
+ ipad[i] ^= key[i];
+ opad[i] ^= key[i];
+ }
+
+ SHA256_Init(&c);
+ SHA256_Update(&c, ipad, sizeof ipad);
+ SHA256_Update(&c, msg, msglen);
+ SHA256_Final(inner, &c);
+
+ SHA256_Init(&c);
+ SHA256_Update(&c, opad, sizeof opad);
+ SHA256_Update(&c, inner, sizeof inner);
+ SHA256_Final(out, &c);
+}
+
+/* True when the candidate is not a usable P-256 scalar: zero, or >= n. */
+static int out_of_range(const unsigned char c[32]) {
+ int i, nonzero = 0;
+
+ for (i = 0; i < 32; i++)
+ if (c[i]) {
+ nonzero = 1;
+ break;
+ }
+ if (!nonzero) return 1;
+
+ for (i = 0; i < 32; i++) {
+ if (c[i] < P256_N[i]) return 0;
+ if (c[i] > P256_N[i]) return 1;
+ }
+ return 1; /* exactly n */
+}
+
+static volatile int g_found = 0;
+static uint64_t g_found_k = 0;
+static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER;
+
+struct span {
+ uint64_t start, stride, limit;
+};
+
+static void *worker(void *arg) {
+ struct span *s = arg;
+ unsigned char seed[32], prk[32], info[EC_LABEL_LEN + 2], cand[32];
+ unsigned char zero_salt[32];
+ uint64_t k;
+
+ memset(zero_salt, 0, sizeof zero_salt);
+ memcpy(seed, SEED_PREFIX, SEED_PREFIX_LEN);
+ memcpy(info, EC_LABEL, EC_LABEL_LEN);
+ info[EC_LABEL_LEN] = 0x00; /* counter i = 0 */
+ info[EC_LABEL_LEN + 1] = 0x01; /* HKDF-Expand block counter T(1) */
+
+ for (k = s->start; k < s->limit && !g_found; k += s->stride) {
+ int i;
+ for (i = 0; i < 8; i++) seed[24 + i] = (unsigned char)(k >> (56 - 8 * i));
+
+ /* HKDF-Extract with a zero-length salt: RFC 5869 substitutes
+ * HashLen zero bytes as the HMAC key. */
+ hmac_sha256(zero_salt, sizeof zero_salt, seed, sizeof seed, prk);
+ /* HKDF-Expand, first and only 32-byte block. */
+ hmac_sha256(prk, sizeof prk, info, sizeof info, cand);
+
+ /* Cheap filter first: a candidate >= n must open 0xFFFFFFFF. */
+ if (cand[0] != 0xff || cand[1] != 0xff || cand[2] != 0xff ||
+ cand[3] != 0xff)
+ continue;
+
+ if (out_of_range(cand)) {
+ pthread_mutex_lock(&g_mu);
+ if (!g_found || k < g_found_k) g_found_k = k;
+ g_found = 1;
+ pthread_mutex_unlock(&g_mu);
+ return NULL;
+ }
+ }
+ return NULL;
+}
+
+int main(int argc, char **argv) {
+ long nthreads = (argc > 1) ? strtol(argv[1], NULL, 10) : 8;
+ pthread_t *th;
+ struct span *spans;
+ long i;
+
+ if (nthreads < 1) nthreads = 1;
+ th = calloc((size_t)nthreads, sizeof *th);
+ spans = calloc((size_t)nthreads, sizeof *spans);
+ if (!th || !spans) return 1;
+
+ for (i = 0; i < nthreads; i++) {
+ spans[i].start = (uint64_t)i;
+ spans[i].stride = (uint64_t)nthreads;
+ spans[i].limit = UINT64_C(1) << 40;
+ pthread_create(&th[i], NULL, worker, &spans[i]);
+ }
+ for (i = 0; i < nthreads; i++) pthread_join(th[i], NULL);
+
+ if (!g_found) {
+ fprintf(stderr, "seedsearch: no rejecting seed found\n");
+ return 1;
+ }
+ printf("%" PRIu64 "\n", g_found_k);
+ return 0;
+}