/* * gen — produce vectors/contra-v1.json from PROTOCOL.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 #include #include #include #include #include #include #include #include #include #include #include /* ---------------------------------------------------------------- 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; } /* The inverse, for transcribing published anchors as the RFC prints them * rather than as a wall of 0x-prefixed bytes nobody can proofread. */ static int hex2bin(const char *s, unsigned char *out, size_t n) { size_t i; if (strlen(s) != n * 2) return 0; for (i = 0; i < n; i++) { unsigned v; if (sscanf(s + i * 2, "%2x", &v) != 1) return 0; out[i] = (unsigned char)v; } return 1; } 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"); } /* ------------------------------------------------- sealing to a public key */ #define ECIES_LABEL "contra/v1/ecies" /* * The ECDH primitive: the X coordinate of d*P, big-endian at the full field * width. Y is discarded, per SEC 1 3.3.1 — which is why the anchor below is * checked against RFC 5903 rather than trusted. */ static void ecdh_shared(const unsigned char d[32], const unsigned char peer[65], unsigned char out[32]) { EC_GROUP *g = EC_GROUP_new_by_curve_name(NID_X9_62_prime256v1); EC_POINT *p, *r; BIGNUM *s, *x; if (!g) die("P-256 group"); p = EC_POINT_new(g); r = EC_POINT_new(g); x = BN_new(); s = BN_bin2bn(d, 32, NULL); if (!p || !r || !x || !s) die("ecdh alloc"); if (!EC_POINT_oct2point(g, p, peer, 65, NULL)) die("peer point"); if (!EC_POINT_mul(g, r, NULL, p, s, NULL)) die("ecdh mul"); if (EC_POINT_is_at_infinity(g, r)) die("ecdh degenerate"); if (!EC_POINT_get_affine_coordinates(g, r, x, NULL, NULL)) die("ecdh x"); if (BN_bn2binpad(x, out, 32) != 32) die("ecdh x width"); BN_free(s); BN_free(x); EC_POINT_free(r); EC_POINT_free(p); EC_GROUP_free(g); } /* * The AEAD key for a message sealed to a static public key. The sender mints * a fresh ephemeral pair per message, so the key is new even though the * recipient's is not. * * Both public points go in the HKDF salt, not just the shared secret. The * shared secret alone names a *relationship*, not a message: two senders who * somehow reached the same secret would derive the same key, and a recipient * could be talked into believing a message came from the wrong ephemeral key. * Binding the pair is the standard fix and costs nothing. */ static void ecies_key(const unsigned char eph_pub[65], const unsigned char recipient_pub[65], const unsigned char shared[32], unsigned char out[32]) { unsigned char salt[130]; memcpy(salt, eph_pub, 65); memcpy(salt + 65, recipient_pub, 65); hkdf(shared, 32, salt, sizeof salt, (const unsigned char *)ECIES_LABEL, sizeof ECIES_LABEL - 1, out, 32); } /* --------------------------------------------------------- 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 PROTOCOL.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 PROTOCOL.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"); } /* * RFC 5903 section 8.1 — ECDH over P-256. The initiator's private scalar * i against the responder's public point gr, giving the shared X the RFC * prints as "g^ir". Discarding Y is the whole of the primitive, so this * anchor is what stops a plausible-looking wrong answer. */ { static const unsigned char rfc5903_i[32] = { 0xc8, 0x8f, 0x01, 0xf5, 0x10, 0xd9, 0xac, 0x3f, 0x70, 0xa2, 0x92, 0xda, 0xa2, 0x31, 0x6d, 0xe5, 0x44, 0xe9, 0xaa, 0xb8, 0xaf, 0xe8, 0x40, 0x49, 0xc6, 0x2a, 0x9c, 0x57, 0x86, 0x2d, 0x14, 0x33, }; unsigned char gr[65] = {0x04}; unsigned char shared[32]; if (!hex2bin("d12dfb5289c8d4f81208b70270398c342296970a0bccb74c736fc75" "54494bf63", gr + 1, 32) || !hex2bin("56fbf3ca366cc23e8157854c13c58d6aac23f046ada30f8353e74f3" "3039872ab", gr + 33, 32)) die("rfc5903 responder point"); ecdh_shared(rfc5903_i, gr, shared); expect("RFC 5903 ECDH P-256", shared, 32, "d6840f6b42f6edafd13116e0e12565202fef8e9ece7dce03812464d04b94" "42de"); } } /* ------------------------------------------------------------------- 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]; unsigned char ho_seed_r[32], ho_seed_e[32], ho_shared[32], ho_key[32]; unsigned char ho_nonce[12], ho_aad[256], ho_ct[16], ho_tag[16]; size_t ho_aadlen; size_t item_aadlen, item_ptlen; struct p256 lk_pw, lk_rc, lk_reject, ho_recipient, ho_ephemeral; 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); fixture("handoff/recipient_seed", ho_seed_r, 32); fixture("handoff/ephemeral_seed", ho_seed_e, 32); fixture("nonce/handoff", ho_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 handoff: an Account Key sealed to a fresh device's ephemeral public * key, which is the one place in the protocol where a secret travels * through the server. Both keypairs come from seeds here only so the * vector is reproducible — in the app both are freshly random. * * The object id in the AAD is the ephemeral public key itself, so the * envelope is bound to the device it was sealed for and cannot be * replayed at a second one. */ p256_from_seed(ho_seed_r, &ho_recipient); p256_from_seed(ho_seed_e, &ho_ephemeral); ecdh_shared(ho_ephemeral.d, ho_recipient.pub, ho_shared); ecies_key(ho_ephemeral.pub, ho_recipient.pub, ho_shared, ho_key); ho_aadlen = build_aad(account_id, 16, "handoff", ho_recipient.pub, 65, 1, ho_aad, sizeof ho_aad); aes256gcm(ho_key, ho_nonce, ho_aad, ho_aadlen, account_key, 16, ho_ct, ho_tag); /* The receiving device derives the same key from the other side. */ { unsigned char back[32]; ecdh_shared(ho_recipient.d, ho_ephemeral.pub, back); if (memcmp(back, ho_shared, 32) != 0) die("ECDH is not symmetric"); } /* 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\": \"PROTOCOL.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(" \"handoff\": {\n"); printf(" \"note\": \"PROTOCOL.md rung 1: an enrolled device seals the " "Account Key to a fresh device's ephemeral public key, and the " "server relays a blob it cannot open\",\n"); printf(" \"shared_secret\": \"X(d*P), big-endian at the field width; Y " "discarded, per SEC 1 3.3.1\",\n"); printf(" \"key\": \"HKDF-SHA-256(ikm=shared_secret, " "salt=ephemeral_public || recipient_public, " "info=\\\"contra/v1/ecies\\\", L=32)\",\n"); printf(" \"envelope\": \"ephemeral_public(65) || nonce(12) || " "ciphertext || tag(16)\",\n"); need_comma = 0; indent = " "; field("recipient_seed_hex", "\"%s\"", hex(ho_seed_r, 32)); field("recipient_scalar_hex", "\"%s\"", hex(ho_recipient.d, 32)); field("recipient_public_hex", "\"%s\"", hex(ho_recipient.pub, 65)); field("ephemeral_seed_hex", "\"%s\"", hex(ho_seed_e, 32)); field("ephemeral_scalar_hex", "\"%s\"", hex(ho_ephemeral.d, 32)); field("ephemeral_public_hex", "\"%s\"", hex(ho_ephemeral.pub, 65)); field("shared_secret_hex", "\"%s\"", hex(ho_shared, 32)); field("key_hex", "\"%s\"", hex(ho_key, 32)); field("account_id_hex", "\"%s\"", hex(account_id, 16)); field("object_type", "\"handoff\""); field("version", "1"); field("nonce_hex", "\"%s\"", hex(ho_nonce, 12)); field("aad_hex", "\"%s\"", hex(ho_aad, ho_aadlen)); field("plaintext_hex", "\"%s\"", hex(account_key, 16)); field("ciphertext_hex", "\"%s\"", hex(ho_ct, 16)); field("tag_hex", "\"%s\"", hex(ho_tag, 16)); printf("\n },\n"); printf(" \"account_chain\": {\n"); printf(" \"note\": \"the whole of PROTOCOL.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; }