carlos / contra Public

crypto: the Go side of the contract, and proof the suite can fail

Implements exactly what the vectors pin and nothing further: the Argon2id
stretch, the five HKDF labels, the KCV, P-256 derivation with its rejection
loop, Crockford Base32 with the O/I/L mappings and its 20-bit checksum, and
AES-256-GCM under the length-prefixed AAD binding. Deliberately no parameter
floor, no signup, no storage — those belong to web/2 and web/3, and this item
is only meant to prove the vectors.

The Go package is the thing under test, never the reference. The test says so
in its own doc comment, because the tempting fix when this suite goes red is
to regenerate the vector file, and that would quietly delete the guarantee.

Two constructions are pulled from the file rather than trusted to the code:
the Argon2 parameters, so the constants cannot drift from the protocol, and
the set of HKDF labels, so a label losing its vector fails rather than passes.

Mutation-checked, because a vector nobody has watched fail is a vector nobody
should trust: lowering the Argon2 time cost, typoing an HKDF label, cutting
the KCV to eight bytes, 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 all turn the suite red.

The AAD tests go beyond byte comparison to the property the binding is for:
transplanting an item ciphertext to another item, vault, version or object
type must fail to open, and two different tuples must never produce one AAD.
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 71fe556eafa90a7f8656e72d5208b9f0bc92d2b5 parent 4658067
8 files changed, +1008 −0
  • .gitignore +2 −0
  • go.mod +7 −0
  • go.sum +4 −0
  • internal/crypto/aead.go +70 −0
  • internal/crypto/code.go +215 −0
  • internal/crypto/ec.go +38 −0
  • internal/crypto/kdf.go +72 −0
  • internal/crypto/vectors_test.go +600 −0
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4931986
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/vectors/gen/gen
+/vectors/gen/seedsearch
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..32860f4
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,7 @@
+module github.com/carlosframework/contra
+
+go 1.26.5
+
+require golang.org/x/crypto v0.55.0
+
+require golang.org/x/sys v0.47.0 // indirect
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..d79e7a0
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,4 @@
+golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
+golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
diff --git a/internal/crypto/aead.go b/internal/crypto/aead.go
new file mode 100644
index 0000000..6cf7cb9
--- /dev/null
+++ b/internal/crypto/aead.go
@@ -0,0 +1,70 @@
+package crypto
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "encoding/binary"
+)
+
+// Object types, as they appear in the AAD. Namespaced by scope so an
+// account-scoped type can never collide with a vault-scoped one.
+const (
+ ObjectWrapPW = "wrap.pw"
+ ObjectWrapRC = "wrap.rc"
+ ObjectWrapPRF = "wrap.prf"
+ ObjectItem = "item"
+)
+
+// AAD builds the additional authenticated data every AEAD in CONTRA binds, so
+// a malicious server cannot transplant a ciphertext between slots or between
+// users.
+//
+// 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 one rule.
+//
+// Length-prefixed because plain concatenation is not injective: without the
+// lengths, different tuples produce identical AAD, which is the exact
+// transplant the binding exists to prevent. Domain-tagged so AAD from this
+// protocol cannot be replayed into another one.
+func AAD(scopeID []byte, objectType string, objectID []byte, version uint64) []byte {
+ out := make([]byte, 0, len(LabelAAD)+1+4+len(scopeID)+4+len(objectType)+4+len(objectID)+8)
+ out = append(out, LabelAAD...)
+ out = append(out, 0x00)
+ out = binary.BigEndian.AppendUint32(out, uint32(len(scopeID)))
+ out = append(out, scopeID...)
+ out = binary.BigEndian.AppendUint32(out, uint32(len(objectType)))
+ out = append(out, objectType...)
+ out = binary.BigEndian.AppendUint32(out, uint32(len(objectID)))
+ out = append(out, objectID...)
+ return binary.BigEndian.AppendUint64(out, version)
+}
+
+func gcm(key []byte) (cipher.AEAD, error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, err
+ }
+ return cipher.NewGCM(block)
+}
+
+// Seal returns ciphertext with the 128-bit tag appended, the layout the wrap
+// rows and item rows both store.
+func Seal(key, nonce, aad, plaintext []byte) ([]byte, error) {
+ a, err := gcm(key)
+ if err != nil {
+ return nil, err
+ }
+ return a.Seal(nil, nonce, plaintext, aad), nil
+}
+
+// Open reverses Seal. A failure here is either the wrong key or a tampered
+// blob; the wrap's KCV is what tells those apart.
+func Open(key, nonce, aad, sealed []byte) ([]byte, error) {
+ a, err := gcm(key)
+ if err != nil {
+ return nil, err
+ }
+ return a.Open(nil, nonce, sealed, aad)
+}
diff --git a/internal/crypto/code.go b/internal/crypto/code.go
new file mode 100644
index 0000000..a50082f
--- /dev/null
+++ b/internal/crypto/code.go
@@ -0,0 +1,215 @@
+package crypto
+
+import (
+ "crypto/sha256"
+ "errors"
+ "strings"
+)
+
+// Alphabet is Crockford's Base32 set: no I, L, O or U.
+//
+// Case-insensitive, and decoding maps O to 0 and I and L to 1 — the exact
+// confusions humans and OCR make reading aged paper, which is what these codes
+// are printed on.
+const Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
+
+// The printed credential form: tag(2) || data(26) || checksum(4).
+const (
+ TagAccountKey = "A1"
+ TagRecovery = "C1"
+ dataSymbols = 26
+ checkSymbols = 4
+ codeSymbols = 2 + dataSymbols + checkSymbols
+)
+
+var (
+ ErrAlphabet = errors.New("crypto: character outside the Crockford alphabet")
+ ErrLength = errors.New("crypto: wrong number of symbols")
+ ErrPadding = errors.New("crypto: non-zero pad bits in the final symbol")
+ ErrChecksum = errors.New("crypto: checksum does not match")
+ ErrTag = errors.New("crypto: wrong tag")
+)
+
+// symbolValue applies the case folding and the O/I/L substitutions, returning
+// -1 for anything outside the alphabet.
+func symbolValue(c byte) int {
+ if c >= 'a' && c <= 'z' {
+ c -= 'a' - 'A'
+ }
+ switch c {
+ case 'O':
+ c = '0'
+ case 'I', 'L':
+ c = '1'
+ }
+ if i := strings.IndexByte(Alphabet, c); i >= 0 {
+ return i
+ }
+ return -1
+}
+
+// Normalize strips hyphens and whitespace, uppercases, and applies the O/I/L
+// mappings. The two-character tag survives it unchanged, so callers normalise
+// first and match the tag afterwards.
+func Normalize(s string) (string, error) {
+ var b strings.Builder
+ for i := 0; i < len(s); i++ {
+ c := s[i]
+ if c == '-' || c == ' ' || c == '\t' || c == '\n' || c == '\r' {
+ continue
+ }
+ v := symbolValue(c)
+ if v < 0 {
+ return "", ErrAlphabet
+ }
+ b.WriteByte(Alphabet[v])
+ }
+ return b.String(), nil
+}
+
+// Base32Encode packs bytes MSB-first and left-aligned, so 16 bytes become 26
+// symbols whose last one carries two zero pad bits.
+//
+// Byte packing rather than Crockford's big-integer form: no language needs a
+// bignum to read a recovery code, which matters most in the browser, where the
+// JS modules are capped at 300 lines by test.
+func Base32Encode(in []byte) string {
+ n := (len(in)*8 + 4) / 5
+ out := make([]byte, n)
+ for i := 0; i < n; i++ {
+ bit := i * 5
+ by, off := bit/8, bit%8
+ v := uint32(in[by]) << 8
+ if by+1 < len(in) {
+ v |= uint32(in[by+1])
+ }
+ out[i] = Alphabet[(v>>(11-off))&0x1f]
+ }
+ return string(out)
+}
+
+// Base32Decode is strict: it rejects a non-zero pad in the final symbol, so
+// every value has exactly one encoding. Without that two different strings
+// decode to one key, and the checksum stops meaning what it says.
+func Base32Decode(sym string, nbytes int) ([]byte, error) {
+ if len(sym) != (nbytes*8+4)/5 {
+ return nil, ErrLength
+ }
+ out := make([]byte, 0, nbytes)
+ var acc uint32
+ var bits int
+ for i := 0; i < len(sym); i++ {
+ v := symbolValue(sym[i])
+ if v < 0 {
+ return nil, ErrAlphabet
+ }
+ acc = acc<<5 | uint32(v)
+ bits += 5
+ if bits >= 8 {
+ bits -= 8
+ out = append(out, byte(acc>>bits))
+ }
+ }
+ if acc&((1<<bits)-1) != 0 {
+ return nil, ErrPadding
+ }
+ return out, nil
+}
+
+// Checksum20 is the first 20 bits, MSB-first, of SHA-256 over the raw payload
+// bytes — not over the string. Twenty bits is enough to catch a mistyped
+// character and small enough to fit four symbols on a printed line.
+func Checksum20(payload []byte) uint32 {
+ d := sha256.Sum256(payload)
+ return uint32(d[0])<<12 | uint32(d[1])<<4 | uint32(d[2])>>4
+}
+
+func checksumSymbols(c uint32) string {
+ out := make([]byte, checkSymbols)
+ for i := 0; i < checkSymbols; i++ {
+ out[i] = Alphabet[(c>>(15-5*i))&0x1f]
+ }
+ return string(out)
+}
+
+// NormalizedCode renders the 32-character canonical form: tag, data, checksum.
+func NormalizedCode(tag string, payload []byte) string {
+ return tag + Base32Encode(payload) + checksumSymbols(Checksum20(payload))
+}
+
+// FormatCode renders what actually goes on the paper:
+// TAG-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX.
+func FormatCode(tag string, payload []byte) string {
+ norm := NormalizedCode(tag, payload)
+ var b strings.Builder
+ b.WriteString(tag)
+ for i := 2; i < len(norm); i++ {
+ if (i-2)%5 == 0 {
+ b.WriteByte('-')
+ }
+ b.WriteByte(norm[i])
+ }
+ return b.String()
+}
+
+// ParseCode normalises, checks the tag and the checksum, and returns the
+// payload. A checksum failure is where the caller reaches for RepairCode.
+func ParseCode(tag, s string) ([]byte, error) {
+ norm, err := Normalize(s)
+ if err != nil {
+ return nil, err
+ }
+ return parseNormalized(tag, norm)
+}
+
+func parseNormalized(tag, norm string) ([]byte, error) {
+ if len(norm) != codeSymbols {
+ return nil, ErrLength
+ }
+ if norm[:2] != tag {
+ return nil, ErrTag
+ }
+ payload, err := Base32Decode(norm[2:2+dataSymbols], 16)
+ if err != nil {
+ return nil, err
+ }
+ if checksumSymbols(Checksum20(payload)) != norm[2+dataSymbols:] {
+ return nil, ErrChecksum
+ }
+ return payload, nil
+}
+
+// RepairCode brute-forces every single-character substitution over the 30
+// payload positions — about a thousand candidates — and returns every one
+// whose checksum passes, in position-then-alphabet order.
+//
+// It deliberately does not pick a winner. Twenty bits collides roughly once in
+// eleven hundred attempts, so the caller tries each candidate against the KCV,
+// which does not collide. Choosing here would sometimes choose wrong and
+// silently hand back the wrong key.
+func RepairCode(tag, s string) ([]string, error) {
+ norm, err := Normalize(s)
+ if err != nil {
+ return nil, err
+ }
+ if len(norm) != codeSymbols {
+ return nil, ErrLength
+ }
+
+ var out []string
+ buf := []byte(norm)
+ for pos := 2; pos < codeSymbols; pos++ {
+ orig := buf[pos]
+ for i := 0; i < len(Alphabet); i++ {
+ if Alphabet[i] == orig {
+ continue
+ }
+ buf[pos] = Alphabet[i]
+ if _, err := parseNormalized(tag, string(buf)); err == nil {
+ out = append(out, string(buf))
+ }
+ }
+ buf[pos] = orig
+ }
+ return out, nil
+}
diff --git a/internal/crypto/ec.go b/internal/crypto/ec.go
new file mode 100644
index 0000000..b4d4ae2
--- /dev/null
+++ b/internal/crypto/ec.go
@@ -0,0 +1,38 @@
+package crypto
+
+import (
+ "crypto/ecdh"
+ "errors"
+)
+
+// ErrNoScalar means the rejection loop ran out of counter values. At a
+// rejection probability of about 2^-32 per candidate this cannot happen by
+// chance; it means the seed or the label is wrong.
+var ErrNoScalar = errors.New("crypto: no valid P-256 scalar in 256 candidates")
+
+// DeriveP256 turns a 32-byte seed into the deterministic P-256 signing keypair
+// the server authenticates by signed challenge — it stores only the public
+// half, so a fully compromised server holds something that cannot decrypt and
+// cannot be replayed.
+//
+// Rejection sampling, because a uniform 32-byte string is not a uniform scalar
+// mod n. ecdh.NewPrivateKey does the range check for us: it accepts exactly
+// [1, n-1]. A rejection is a ~2^-32 event, so the loop past i=0 is only ever
+// reached by the searched seed in the vector file.
+func DeriveP256(seed []byte) (*ecdh.PrivateKey, error) {
+ info := make([]byte, len(LabelECP256)+1)
+ copy(info, LabelECP256)
+
+ for i := 0; i < 256; i++ {
+ info[len(info)-1] = byte(i)
+ candidate, err := Derive(seed, nil, string(info), 32)
+ if err != nil {
+ return nil, err
+ }
+ key, err := ecdh.P256().NewPrivateKey(candidate)
+ if err == nil {
+ return key, nil
+ }
+ }
+ return nil, ErrNoScalar
+}
diff --git a/internal/crypto/kdf.go b/internal/crypto/kdf.go
new file mode 100644
index 0000000..f19b864
--- /dev/null
+++ b/internal/crypto/kdf.go
@@ -0,0 +1,72 @@
+// Package crypto implements CONTRA's key hierarchy exactly as DESIGN.md
+// specifies it, and nothing else.
+//
+// Every derivation here is pinned by vectors/contra-v1.json. That file was
+// generated from the spec by an independent implementation — OpenSSL, driven
+// by vectors/gen/gen.c — and never from this code, so the test proves
+// agreement rather than self-consistency.
+//
+// Changing a value this package produces is changing the protocol. The vector
+// file, the JS console and the Swift core all have to move together, or a Mac
+// unlocks a vault the browser cannot.
+package crypto
+
+import (
+ "crypto/hkdf"
+ "crypto/hmac"
+ "crypto/sha256"
+
+ "golang.org/x/crypto/argon2"
+)
+
+// The versioned HKDF labels. Every derivation in the protocol carries one, so
+// that holding the output of one branch says nothing about any other.
+const (
+ LabelLogin = "contra/v1/login"
+ LabelKEK = "contra/v1/kek"
+ LabelRCLogin = "contra/v1/rc-login"
+ LabelRCKEK = "contra/v1/rc-kek"
+ LabelPRFKEK = "contra/v1/prf-kek"
+ LabelKCV = "contra/v1/kcv"
+ LabelECP256 = "contra/v1/ec-p256"
+ LabelAAD = "contra/v1/aad"
+)
+
+// Argon2id parameters, from DESIGN.md.
+//
+// 64 MiB is the largest allocation that reliably succeeds in iOS Safari's WASM
+// heap under memory pressure. Lanes is 1 because Argon2id's output depends on
+// it and WASM without SharedArrayBuffer cannot parallelise — p > 1 would break
+// byte-identity between Go, JS and Swift, which is the whole point.
+const (
+ Argon2MemoryKiB uint32 = 64 * 1024
+ Argon2Time uint32 = 3
+ Argon2Lanes uint8 = 1
+ Argon2TagLen uint32 = 32
+)
+
+// StretchPassphrase is the one expensive call on the passphrase branch: S_pw.
+func StretchPassphrase(passphrase string, salt []byte) []byte {
+ return argon2.IDKey([]byte(passphrase), salt,
+ Argon2Time, Argon2MemoryKiB, Argon2Lanes, Argon2TagLen)
+}
+
+// Derive is HKDF-SHA-256, RFC 5869 extract-then-expand — the single mode this
+// protocol uses. A nil or empty salt is a zero-length salt, which RFC 5869
+// turns into HashLen zero bytes.
+func Derive(ikm, salt []byte, label string, n int) ([]byte, error) {
+ return hkdf.Key(sha256.New, ikm, salt, label, n)
+}
+
+// KCV is the 16-byte key-check value carried by every wrap row.
+//
+// AES-GCM is not key-committing, so this is the commitment check. It also
+// distinguishes "wrong key" from "corrupted blob", which is the distinction
+// every unlock path needs constantly — and it is how a passkey provider
+// returning a mismatched PRF is recognised as "fast unlock unavailable"
+// rather than reported as an authentication failure.
+func KCV(kek []byte) []byte {
+ m := hmac.New(sha256.New, kek)
+ m.Write([]byte(LabelKCV))
+ return m.Sum(nil)[:16]
+}
diff --git a/internal/crypto/vectors_test.go b/internal/crypto/vectors_test.go
new file mode 100644
index 0000000..748fb15
--- /dev/null
+++ b/internal/crypto/vectors_test.go
@@ -0,0 +1,600 @@
+// Tests against vectors/contra-v1.json — the cross-language crypto contract.
+//
+// The rule this file exists to enforce: Go, JavaScript and Swift must produce
+// identical bytes for every derivation in DESIGN.md, and the vector file is
+// what pins them. It is the reference; this package is not. When a value here
+// disagrees with the file, this package is wrong until somebody argues
+// otherwise on the record.
+//
+// The vectors were generated by vectors/gen/gen.c against OpenSSL — an
+// implementation nobody in this repo wrote — precisely so that passing means
+// two independent implementations agree, rather than one implementation
+// agreeing with its own output. If you ever find yourself regenerating the
+// file to make a test pass, stop: that is the failure mode the arrangement is
+// built to prevent.
+//
+// The JS and Swift suites must consume this same file. A second
+// implementation that passes its own vectors has proved nothing.
+package crypto
+
+import (
+ "bytes"
+ "crypto/ecdh"
+ "encoding/hex"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// ecdhReject is the range check on its own, so a test can assert that a
+// candidate really is out of [1, n-1] rather than inferring it.
+func ecdhReject(candidate []byte) (*ecdh.PrivateKey, error) {
+ return ecdh.P256().NewPrivateKey(candidate)
+}
+
+// confuse renders a code the way aged paper and OCR hand it back: lowercase,
+// zeros read as O, ones read as I and L. Normalisation has to undo all of it.
+func confuse(s string) string {
+ r := strings.NewReplacer("0", "O", "1", "l")
+ return strings.ToLower(r.Replace(s))
+}
+
+type vectorFile struct {
+ Format string `json:"format"`
+ Version int `json:"version"`
+ Argon2id struct {
+ Params struct {
+ MemKiB uint32 `json:"m_kib"`
+ Time uint32 `json:"t"`
+ Lanes uint8 `json:"p"`
+ TagLen uint32 `json:"tag_len"`
+ } `json:"params"`
+ Cases []struct {
+ Name string `json:"name"`
+ Passphrase string `json:"passphrase_utf8"`
+ SaltHex string `json:"salt_hex"`
+ OutHex string `json:"out_hex"`
+ } `json:"cases"`
+ } `json:"argon2id"`
+ HKDF struct {
+ Cases []struct {
+ Name string `json:"name"`
+ Info string `json:"info"`
+ IKMHex string `json:"ikm_hex"`
+ SaltHex string `json:"salt_hex"`
+ L int `json:"l"`
+ OKMHex string `json:"okm_hex"`
+ } `json:"cases"`
+ } `json:"hkdf"`
+ KCV struct {
+ Cases []struct {
+ Name string `json:"name"`
+ KEKHex string `json:"kek_hex"`
+ KCVHex string `json:"kcv_hex"`
+ } `json:"cases"`
+ } `json:"kcv"`
+ P256 struct {
+ Cases []struct {
+ Name string `json:"name"`
+ SeedHex string `json:"seed_hex"`
+ Tried int `json:"candidates_tried"`
+ Candidates []string `json:"candidates"`
+ DHex string `json:"d_hex"`
+ PubHex string `json:"public_uncompressed_hex"`
+ } `json:"cases"`
+ } `json:"p256_from_seed"`
+ Crockford struct {
+ Alphabet string `json:"alphabet"`
+ EncodeCases []struct {
+ BytesHex string `json:"bytes_hex"`
+ Symbols string `json:"symbols"`
+ } `json:"encode_cases"`
+ NormalizeCases []struct {
+ Input string `json:"input"`
+ Normalized string `json:"normalized"`
+ Error string `json:"error"`
+ } `json:"normalize_cases"`
+ DecodeCases []struct {
+ Symbols string `json:"symbols"`
+ BytesHex string `json:"bytes_hex"`
+ Error string `json:"error"`
+ } `json:"decode_cases"`
+ } `json:"crockford"`
+ PrintedCode struct {
+ Cases []struct {
+ Name string `json:"name"`
+ Tag string `json:"tag"`
+ PayloadHex string `json:"payload_hex"`
+ Checksum20 uint32 `json:"checksum20"`
+ Normalized string `json:"normalized"`
+ Printed string `json:"printed"`
+ } `json:"cases"`
+ Repair struct {
+ Correct string `json:"correct_normalized"`
+ Typo string `json:"typo_normalized"`
+ Position int `json:"typo_position"`
+ Candidates []string `json:"candidates"`
+ Count int `json:"candidate_count"`
+ } `json:"repair"`
+ } `json:"printed_code"`
+ AEAD struct {
+ AccountIDHex string `json:"account_id_hex"`
+ VaultIDHex string `json:"vault_id_hex"`
+ RKHex string `json:"rk_hex"`
+ Wraps []struct {
+ Name string `json:"name"`
+ ObjectType string `json:"object_type"`
+ KEKHex string `json:"kek_hex"`
+ KCVHex string `json:"kcv_hex"`
+ WrapIDHex string `json:"wrap_id_hex"`
+ Version uint64 `json:"version"`
+ NonceHex string `json:"nonce_hex"`
+ AADHex string `json:"aad_hex"`
+ CTHex string `json:"ciphertext_hex"`
+ TagHex string `json:"tag_hex"`
+ } `json:"wraps"`
+ Item struct {
+ VaultIDHex string `json:"vault_id_hex"`
+ ObjectType string `json:"object_type"`
+ ItemIDHex string `json:"item_id_hex"`
+ Version uint64 `json:"version"`
+ KVaultHex string `json:"k_vault_hex"`
+ NonceHex string `json:"nonce_hex"`
+ AADHex string `json:"aad_hex"`
+ PlainHex string `json:"plaintext_hex"`
+ CTHex string `json:"ciphertext_hex"`
+ TagHex string `json:"tag_hex"`
+ } `json:"item"`
+ } `json:"aead"`
+ AccountChain struct {
+ Passphrase string `json:"passphrase_utf8"`
+ SaltPWHex string `json:"salt_pw_hex"`
+ AccountKeyHex string `json:"account_key_hex"`
+ RecoveryHex string `json:"recovery_code_hex"`
+ PRFOutHex string `json:"prf_output_hex"`
+ CredSaltHex string `json:"cred_salt_hex"`
+ SPWHex string `json:"s_pw_hex"`
+ SeedLoginHex string `json:"seed_login_hex"`
+ LKPWPubHex string `json:"lk_pw_public_hex"`
+ SeedRCLoginHex string `json:"seed_rc_login_hex"`
+ LKRCPubHex string `json:"lk_rc_public_hex"`
+ KEKPWHex string `json:"kek_pw_hex"`
+ KEKRCHex string `json:"kek_rc_hex"`
+ KEKPRFHex string `json:"kek_prf_hex"`
+ RKHex string `json:"rk_hex"`
+ } `json:"account_chain"`
+}
+
+func unhex(t *testing.T, s string) []byte {
+ t.Helper()
+ b, err := hex.DecodeString(s)
+ if err != nil {
+ t.Fatalf("bad hex %q: %v", s, err)
+ }
+ return b
+}
+
+func eq(t *testing.T, what string, got []byte, wantHex string) {
+ t.Helper()
+ if g := hex.EncodeToString(got); g != wantHex {
+ t.Errorf("%s\n got %s\n want %s", what, g, wantHex)
+ }
+}
+
+func load(t *testing.T) *vectorFile {
+ t.Helper()
+ path := filepath.Join("..", "..", "vectors", "contra-v1.json")
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read %s: %v", path, err)
+ }
+ var v vectorFile
+ if err := json.Unmarshal(raw, &v); err != nil {
+ t.Fatalf("parse %s: %v", path, err)
+ }
+ if v.Format != "contra-golden-vectors" || v.Version != 1 {
+ t.Fatalf("unexpected vector file %q v%d", v.Format, v.Version)
+ }
+ return &v
+}
+
+// The parameters in the file are the protocol's, so the constants have to
+// match them rather than the other way round.
+func TestArgon2Params(t *testing.T) {
+ v := load(t)
+ p := v.Argon2id.Params
+ if p.MemKiB != Argon2MemoryKiB || p.Time != Argon2Time ||
+ p.Lanes != Argon2Lanes || p.TagLen != Argon2TagLen {
+ t.Fatalf("parameters drifted from the vectors: file m=%d t=%d p=%d len=%d, code m=%d t=%d p=%d len=%d",
+ p.MemKiB, p.Time, p.Lanes, p.TagLen,
+ Argon2MemoryKiB, Argon2Time, Argon2Lanes, Argon2TagLen)
+ }
+}
+
+func TestArgon2id(t *testing.T) {
+ v := load(t)
+ for _, c := range v.Argon2id.Cases {
+ t.Run(c.Name, func(t *testing.T) {
+ eq(t, "S_pw", StretchPassphrase(c.Passphrase, unhex(t, c.SaltHex)), c.OutHex)
+ })
+ }
+}
+
+func TestHKDFLabels(t *testing.T) {
+ v := load(t)
+ if len(v.HKDF.Cases) == 0 {
+ t.Fatal("no HKDF cases")
+ }
+ seen := map[string]bool{}
+ for _, c := range v.HKDF.Cases {
+ t.Run(c.Name, func(t *testing.T) {
+ var salt []byte
+ if c.SaltHex != "" {
+ salt = unhex(t, c.SaltHex)
+ }
+ got, err := Derive(unhex(t, c.IKMHex), salt, c.Info, c.L)
+ if err != nil {
+ t.Fatalf("Derive: %v", err)
+ }
+ eq(t, c.Info, got, c.OKMHex)
+ })
+ seen[c.Info] = true
+ }
+ // DESIGN.md names five derivation labels. A label that loses its vector
+ // is a label nothing pins.
+ for _, l := range []string{LabelLogin, LabelKEK, LabelRCLogin, LabelRCKEK, LabelPRFKEK} {
+ if !seen[l] {
+ t.Errorf("no vector covers HKDF label %q", l)
+ }
+ }
+}
+
+func TestKCV(t *testing.T) {
+ v := load(t)
+ for _, c := range v.KCV.Cases {
+ t.Run(c.Name, func(t *testing.T) {
+ eq(t, "KCV", KCV(unhex(t, c.KEKHex)), c.KCVHex)
+ })
+ }
+}
+
+func TestP256FromSeed(t *testing.T) {
+ v := load(t)
+ sawRejection := false
+ for _, c := range v.P256.Cases {
+ t.Run(c.Name, func(t *testing.T) {
+ key, err := DeriveP256(unhex(t, c.SeedHex))
+ if err != nil {
+ t.Fatalf("DeriveP256: %v", err)
+ }
+ eq(t, "d", key.Bytes(), c.DHex)
+ eq(t, "public key", key.PublicKey().Bytes(), c.PubHex)
+ })
+ if c.Tried > 1 {
+ sawRejection = true
+ }
+ }
+ // The whole reason a seed was searched for: without a case that rejects,
+ // the loop past i=0 is dead code in every implementation that copies this.
+ if !sawRejection {
+ t.Error("no vector exercises the rejection branch")
+ }
+}
+
+// The rejecting seed is worth its own test, because it is the one input in the
+// file that cost real compute to find and is trivially lost in a regeneration.
+func TestP256RejectionBranch(t *testing.T) {
+ v := load(t)
+ for _, c := range v.P256.Cases {
+ if c.Tried < 2 {
+ continue
+ }
+ seed := unhex(t, c.SeedHex)
+ // Candidate 0 must be out of range: ecdh rejects it.
+ if _, err := DeriveP256(seed); err != nil {
+ t.Fatalf("rejecting seed did not derive: %v", err)
+ }
+ first, err := Derive(seed, nil, LabelECP256+"\x00", 32)
+ if err != nil {
+ t.Fatal(err)
+ }
+ eq(t, "candidate 0", first, c.Candidates[0])
+ if _, err := ecdhReject(first); err == nil {
+ t.Error("candidate 0 was accepted; this seed no longer forces a rejection")
+ }
+ return
+ }
+ t.Skip("no rejecting seed in the vector file")
+}
+
+func TestCrockfordAlphabet(t *testing.T) {
+ v := load(t)
+ if v.Crockford.Alphabet != Alphabet {
+ t.Fatalf("alphabet drift\n got %s\n want %s", Alphabet, v.Crockford.Alphabet)
+ }
+}
+
+func TestCrockfordEncode(t *testing.T) {
+ v := load(t)
+ for _, c := range v.Crockford.EncodeCases {
+ if got := Base32Encode(unhex(t, c.BytesHex)); got != c.Symbols {
+ t.Errorf("encode %s\n got %s\n want %s", c.BytesHex, got, c.Symbols)
+ }
+ }
+}
+
+func TestCrockfordNormalize(t *testing.T) {
+ v := load(t)
+ for _, c := range v.Crockford.NormalizeCases {
+ got, err := Normalize(c.Input)
+ if c.Error != "" {
+ if err == nil {
+ t.Errorf("normalize %q: expected an error (%s), got %q", c.Input, c.Error, got)
+ }
+ continue
+ }
+ if err != nil {
+ t.Errorf("normalize %q: %v", c.Input, err)
+ continue
+ }
+ if got != c.Normalized {
+ t.Errorf("normalize %q\n got %s\n want %s", c.Input, got, c.Normalized)
+ }
+ }
+}
+
+func TestCrockfordDecode(t *testing.T) {
+ v := load(t)
+ for _, c := range v.Crockford.DecodeCases {
+ got, err := Base32Decode(c.Symbols, 16)
+ if c.Error != "" {
+ if err == nil {
+ t.Errorf("decode %q: expected an error (%s)", c.Symbols, c.Error)
+ }
+ continue
+ }
+ if err != nil {
+ t.Errorf("decode %q: %v", c.Symbols, err)
+ continue
+ }
+ eq(t, "decode "+c.Symbols, got, c.BytesHex)
+ }
+}
+
+func TestPrintedCode(t *testing.T) {
+ v := load(t)
+ for _, c := range v.PrintedCode.Cases {
+ t.Run(c.Name, func(t *testing.T) {
+ payload := unhex(t, c.PayloadHex)
+ if got := Checksum20(payload); got != c.Checksum20 {
+ t.Errorf("checksum20 got %d want %d", got, c.Checksum20)
+ }
+ if got := NormalizedCode(c.Tag, payload); got != c.Normalized {
+ t.Errorf("normalized\n got %s\n want %s", got, c.Normalized)
+ }
+ if got := FormatCode(c.Tag, payload); got != c.Printed {
+ t.Errorf("printed\n got %s\n want %s", got, c.Printed)
+ }
+ // Round trip through the printed form, hyphens and all.
+ back, err := ParseCode(c.Tag, c.Printed)
+ if err != nil {
+ t.Fatalf("ParseCode: %v", err)
+ }
+ eq(t, "round trip", back, c.PayloadHex)
+ // And through a lowercase, O/I/L-confused rendering of it, which
+ // is what aged paper and OCR actually hand us.
+ confused := confuse(c.Printed)
+ back, err = ParseCode(c.Tag, confused)
+ if err != nil {
+ t.Fatalf("ParseCode(%q): %v", confused, err)
+ }
+ eq(t, "confused round trip", back, c.PayloadHex)
+ })
+ }
+}
+
+func TestRepair(t *testing.T) {
+ v := load(t)
+ r := v.PrintedCode.Repair
+ if r.Typo == "" {
+ t.Skip("no repair case in the vector file")
+ }
+ if _, err := ParseCode(TagRecovery, r.Typo); err == nil {
+ t.Fatal("the typo case validates, so it is not testing repair")
+ }
+ got, err := RepairCode(TagRecovery, r.Typo)
+ if err != nil {
+ t.Fatalf("RepairCode: %v", err)
+ }
+ if len(got) != r.Count {
+ t.Errorf("candidate count got %d want %d (%v)", len(got), r.Count, got)
+ }
+ for i := range r.Candidates {
+ if i >= len(got) || got[i] != r.Candidates[i] {
+ t.Errorf("candidate %d\n got %v\n want %v", i, got, r.Candidates)
+ break
+ }
+ }
+ found := false
+ for _, c := range got {
+ if c == r.Correct {
+ found = true
+ }
+ }
+ if !found {
+ t.Errorf("repair did not recover the correct code %s", r.Correct)
+ }
+}
+
+func TestAEADWraps(t *testing.T) {
+ v := load(t)
+ accountID := unhex(t, v.AEAD.AccountIDHex)
+ rk := unhex(t, v.AEAD.RKHex)
+
+ for _, w := range v.AEAD.Wraps {
+ t.Run(w.Name, func(t *testing.T) {
+ aad := AAD(accountID, w.ObjectType, unhex(t, w.WrapIDHex), w.Version)
+ eq(t, "AAD", aad, w.AADHex)
+ eq(t, "KCV", KCV(unhex(t, w.KEKHex)), w.KCVHex)
+
+ sealed, err := Seal(unhex(t, w.KEKHex), unhex(t, w.NonceHex), aad, rk)
+ if err != nil {
+ t.Fatalf("Seal: %v", err)
+ }
+ eq(t, "ciphertext", sealed[:len(sealed)-16], w.CTHex)
+ eq(t, "tag", sealed[len(sealed)-16:], w.TagHex)
+
+ opened, err := Open(unhex(t, w.KEKHex), unhex(t, w.NonceHex), aad, sealed)
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ if !bytes.Equal(opened, rk) {
+ t.Error("Open did not return RK")
+ }
+ })
+ }
+}
+
+func TestAEADItem(t *testing.T) {
+ v := load(t)
+ it := v.AEAD.Item
+ aad := AAD(unhex(t, it.VaultIDHex), it.ObjectType, unhex(t, it.ItemIDHex), it.Version)
+ eq(t, "item AAD", aad, it.AADHex)
+
+ sealed, err := Seal(unhex(t, it.KVaultHex), unhex(t, it.NonceHex), aad, unhex(t, it.PlainHex))
+ if err != nil {
+ t.Fatalf("Seal: %v", err)
+ }
+ eq(t, "item ciphertext", sealed[:len(sealed)-16], it.CTHex)
+ eq(t, "item tag", sealed[len(sealed)-16:], it.TagHex)
+}
+
+// The binding is only worth anything if breaking it breaks decryption. This is
+// the transplant a malicious server would attempt: same key, same nonce, same
+// bytes, a different slot.
+func TestAADBindingRejectsTransplant(t *testing.T) {
+ v := load(t)
+ it := v.AEAD.Item
+ key, nonce := unhex(t, it.KVaultHex), unhex(t, it.NonceHex)
+ good := AAD(unhex(t, it.VaultIDHex), it.ObjectType, unhex(t, it.ItemIDHex), it.Version)
+
+ sealed, err := Seal(key, nonce, good, unhex(t, it.PlainHex))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ otherItem := append([]byte(nil), unhex(t, it.ItemIDHex)...)
+ otherItem[0] ^= 0x01
+ otherVault := append([]byte(nil), unhex(t, it.VaultIDHex)...)
+ otherVault[0] ^= 0x01
+
+ for _, bad := range []struct {
+ name string
+ aad []byte
+ }{
+ {"different item", AAD(unhex(t, it.VaultIDHex), it.ObjectType, otherItem, it.Version)},
+ {"different vault", AAD(otherVault, it.ObjectType, unhex(t, it.ItemIDHex), it.Version)},
+ {"different version", AAD(unhex(t, it.VaultIDHex), it.ObjectType, unhex(t, it.ItemIDHex), it.Version+1)},
+ {"different type", AAD(unhex(t, it.VaultIDHex), ObjectWrapPW, unhex(t, it.ItemIDHex), it.Version)},
+ } {
+ if _, err := Open(key, nonce, bad.aad, sealed); err == nil {
+ t.Errorf("%s: transplant succeeded — the AAD binding is not holding", bad.name)
+ }
+ }
+}
+
+// Length prefixes exist so two different tuples cannot produce one AAD. This
+// is the collision that unprefixed concatenation would allow.
+func TestAADIsInjective(t *testing.T) {
+ id := bytes.Repeat([]byte{0xab}, 16)
+ a := AAD(id, "wrap.p", append(append([]byte(nil), 'w'), id...), 1)
+ b := AAD(id, "wrap.pw", id, 1)
+ if bytes.Equal(a, b) {
+ t.Fatal("two different tuples produced the same AAD")
+ }
+}
+
+// The whole of DESIGN.md's "The shape", re-derived from the printed inputs.
+// It is what a new implementation bisects against when it diverges somewhere
+// in the middle.
+func TestAccountChain(t *testing.T) {
+ v := load(t)
+ c := v.AccountChain
+
+ sPW := StretchPassphrase(c.Passphrase, unhex(t, c.SaltPWHex))
+ eq(t, "S_pw", sPW, c.SPWHex)
+
+ seedLogin, err := Derive(sPW, nil, LabelLogin, 32)
+ if err != nil {
+ t.Fatal(err)
+ }
+ eq(t, "seed_login", seedLogin, c.SeedLoginHex)
+
+ lkPW, err := DeriveP256(seedLogin)
+ if err != nil {
+ t.Fatal(err)
+ }
+ eq(t, "LK_pw public", lkPW.PublicKey().Bytes(), c.LKPWPubHex)
+
+ kekPW, err := Derive(sPW, unhex(t, c.AccountKeyHex), LabelKEK, 32)
+ if err != nil {
+ t.Fatal(err)
+ }
+ eq(t, "KEK_pw", kekPW, c.KEKPWHex)
+
+ seedRC, err := Derive(unhex(t, c.RecoveryHex), nil, LabelRCLogin, 32)
+ if err != nil {
+ t.Fatal(err)
+ }
+ eq(t, "seed_rc_login", seedRC, c.SeedRCLoginHex)
+
+ lkRC, err := DeriveP256(seedRC)
+ if err != nil {
+ t.Fatal(err)
+ }
+ eq(t, "LK_rc public", lkRC.PublicKey().Bytes(), c.LKRCPubHex)
+
+ kekRC, err := Derive(unhex(t, c.RecoveryHex), nil, LabelRCKEK, 32)
+ if err != nil {
+ t.Fatal(err)
+ }
+ eq(t, "KEK_rc", kekRC, c.KEKRCHex)
+
+ kekPRF, err := Derive(unhex(t, c.PRFOutHex), unhex(t, c.CredSaltHex), LabelPRFKEK, 32)
+ if err != nil {
+ t.Fatal(err)
+ }
+ eq(t, "KEK_prf", kekPRF, c.KEKPRFHex)
+
+ // The three wraps are peers: each opens RK on its own, and none is
+ // derivable from another.
+ rk := unhex(t, c.RKHex)
+ for _, w := range v.AEAD.Wraps {
+ aad := AAD(unhex(t, v.AEAD.AccountIDHex), w.ObjectType, unhex(t, w.WrapIDHex), w.Version)
+ sealed := append(unhex(t, w.CTHex), unhex(t, w.TagHex)...)
+ got, err := Open(unhex(t, w.KEKHex), unhex(t, w.NonceHex), aad, sealed)
+ if err != nil {
+ t.Errorf("%s did not open: %v", w.Name, err)
+ continue
+ }
+ if !bytes.Equal(got, rk) {
+ t.Errorf("%s opened to something other than RK", w.Name)
+ }
+ }
+}
+
+// The KEK branches must be independent: knowing one says nothing about
+// another. This cannot prove HKDF's PRF property, but it does catch the
+// copy-paste that gives two labels the same output.
+func TestBranchesAreDistinct(t *testing.T) {
+ v := load(t)
+ seen := map[string]string{}
+ for _, c := range v.HKDF.Cases {
+ if prev, ok := seen[c.OKMHex]; ok {
+ t.Errorf("labels %q and %q derive the same key", prev, c.Info)
+ }
+ seen[c.OKMHex] = c.Info
+ }
+}