shared: seal to a public key, pinned in three implementations
The device handoff (web/5, discussion 11) is the one place in CONTRA where
a secret travels through the server, so "the server relays a blob it cannot
open" has to be true by construction rather than by assertion. That needs
ECIES, which we did not have.
envelope = ephemeral_public(65) || nonce(12) || ciphertext || tag(16)
key = HKDF(ikm = X(d*P), salt = eph_pub || recipient_pub,
info = "contra/v1/ecies", L = 32)
Both public points go into the HKDF salt. The shared secret alone names a
relationship rather than a message, and binding the pair is what stops a
recipient being talked into believing an envelope came from an ephemeral
key that did not produce it.
Generated by vectors/gen/gen.c against OpenSSL, as decision 6 requires, and
the generator now re-computes RFC 5903's P-256 ECDH before it may emit a
byte. That anchor earned its place immediately: it rejected a transcribed
responder point on the first run, which every self-consistent test in the
tree would have accepted.
The vector file gains a section and changes no existing value, so the
pinned contract is extended rather than broken. The AAD names the
recipient's own public key, so an envelope cannot be relayed to a second
device; both the Go and JS suites assert that, and that a different scalar
cannot open it.
Private ECDH keys are imported as a full JWK for the same reason p256.js
does it — a PKCS#8 with the public half omitted is a DataError on Safari,
which is discussion 21.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
10 files changed,
+652
−2
internal/contra/crypto/aead.go+1 −0internal/contra/crypto/ecies.go+108 −0internal/contra/crypto/kdf.go+1 −0internal/contra/crypto/vectors_test.go+114 −0internal/contra/static/crypto/ecies.js+132 −0internal/contra/static/crypto/sealed.js+1 −0internal/contratest/jsmodules_test.go+2 −1test/ecies.test.mjs+109 −0vectors/contra-v1.json+22 −0vectors/gen/gen.c+162 −1
diff --git a/internal/contra/crypto/aead.go b/internal/contra/crypto/aead.go| index 6cf7cb9..35b9812 100644 |
| --- a/internal/contra/crypto/aead.go |
| +++ b/internal/contra/crypto/aead.go |
| @@ -13,6 +13,7 @@ const ( |
| ObjectWrapRC = "wrap.rc" |
| ObjectWrapPRF = "wrap.prf" |
| ObjectItem = "item" |
| + ObjectHandoff = "handoff" |
| ) |
| // AAD builds the additional authenticated data every AEAD in CONTRA binds, so |
diff --git a/internal/contra/crypto/ecies.go b/internal/contra/crypto/ecies.go| new file mode 100644 |
| index 0000000..c12ac4f |
| --- /dev/null |
| +++ b/internal/contra/crypto/ecies.go |
| @@ -0,0 +1,108 @@ |
| +package crypto |
| + |
| +import ( |
| + "crypto/ecdh" |
| + "crypto/rand" |
| + "errors" |
| + "fmt" |
| +) |
| + |
| +// PublicKeyLen is the SEC1 uncompressed P-256 point: 0x04 || X(32) || Y(32). |
| +const PublicKeyLen = 65 |
| + |
| +// NonceLen is the 96-bit AES-GCM nonce every sealed object in CONTRA carries. |
| +const NonceLen = 12 |
| + |
| +// ErrShortEnvelope means the bytes are too short to be an envelope at all, |
| +// which is a different fact from "the tag did not verify" and is worth its own |
| +// error — one is a truncated or wrong-shaped blob, the other is a wrong key. |
| +var ErrShortEnvelope = errors.New("crypto: handoff envelope is too short") |
| + |
| +// ECDH is the raw primitive: the X coordinate of d*P, big-endian at the field |
| +// width, with Y discarded per SEC 1 3.3.1. |
| +// |
| +// Pinned by vectors/contra-v1.json ("handoff"), whose generator checks OpenSSL |
| +// against RFC 5903 before it is allowed to produce a byte. |
| +func ECDH(scalar, peerPublic []byte) ([]byte, error) { |
| + priv, err := ecdh.P256().NewPrivateKey(scalar) |
| + if err != nil { |
| + return nil, fmt.Errorf("crypto: handoff scalar: %w", err) |
| + } |
| + pub, err := ecdh.P256().NewPublicKey(peerPublic) |
| + if err != nil { |
| + return nil, fmt.Errorf("crypto: handoff peer key: %w", err) |
| + } |
| + return priv.ECDH(pub) |
| +} |
| + |
| +// ECIESKey derives the AEAD key for a message sealed to a static public key. |
| +// |
| +// Both public points go into the HKDF salt, not just the shared secret. The |
| +// shared secret alone names a relationship rather than a message: binding the |
| +// pair is what stops a recipient being talked into believing an envelope came |
| +// from an ephemeral key that did not produce it. |
| +func ECIESKey(ephemeralPublic, recipientPublic, shared []byte) ([]byte, error) { |
| + salt := make([]byte, 0, 2*PublicKeyLen) |
| + salt = append(salt, ephemeralPublic...) |
| + salt = append(salt, recipientPublic...) |
| + return Derive(shared, salt, LabelECIES, 32) |
| +} |
| + |
| +// SealTo seals a plaintext to a recipient's public key, minting a fresh |
| +// ephemeral keypair so the AEAD key is new even though the recipient's is not. |
| +// |
| +// The envelope is ephemeral_public(65) || nonce(12) || ciphertext || tag(16). |
| +// The AAD is the caller's, and for the device handoff it names the recipient's |
| +// own public key — so an envelope cannot be replayed at a second device. |
| +func SealTo(recipientPublic, aad, plaintext []byte) ([]byte, error) { |
| + eph, err := ecdh.P256().GenerateKey(rand.Reader) |
| + if err != nil { |
| + return nil, err |
| + } |
| + ephPublic := eph.PublicKey().Bytes() |
| + |
| + shared, err := ECDH(eph.Bytes(), recipientPublic) |
| + if err != nil { |
| + return nil, err |
| + } |
| + key, err := ECIESKey(ephPublic, recipientPublic, shared) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + nonce := make([]byte, NonceLen) |
| + if _, err := rand.Read(nonce); err != nil { |
| + return nil, err |
| + } |
| + sealed, err := Seal(key, nonce, aad, plaintext) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + out := make([]byte, 0, PublicKeyLen+NonceLen+len(sealed)) |
| + out = append(out, ephPublic...) |
| + out = append(out, nonce...) |
| + return append(out, sealed...), nil |
| +} |
| + |
| +// OpenFrom reverses SealTo using the recipient's own scalar. The recipient's |
| +// public key is passed in rather than recomputed because it is bound into the |
| +// derivation, and a caller that has cached the wrong one should be told so by |
| +// a failing tag rather than by silently deriving a different key. |
| +func OpenFrom(scalar, recipientPublic, aad, envelope []byte) ([]byte, error) { |
| + if len(envelope) < PublicKeyLen+NonceLen { |
| + return nil, ErrShortEnvelope |
| + } |
| + ephPublic := envelope[:PublicKeyLen] |
| + nonce := envelope[PublicKeyLen : PublicKeyLen+NonceLen] |
| + |
| + shared, err := ECDH(scalar, ephPublic) |
| + if err != nil { |
| + return nil, err |
| + } |
| + key, err := ECIESKey(ephPublic, recipientPublic, shared) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return Open(key, nonce, aad, envelope[PublicKeyLen+NonceLen:]) |
| +} |
diff --git a/internal/contra/crypto/kdf.go b/internal/contra/crypto/kdf.go| index f19b864..9d360c2 100644 |
| --- a/internal/contra/crypto/kdf.go |
| +++ b/internal/contra/crypto/kdf.go |
| @@ -30,6 +30,7 @@ const ( |
| LabelKCV = "contra/v1/kcv" |
| LabelECP256 = "contra/v1/ec-p256" |
| LabelAAD = "contra/v1/aad" |
| + LabelECIES = "contra/v1/ecies" |
| ) |
| // Argon2id parameters, from DESIGN.md. |
diff --git a/internal/contra/crypto/vectors_test.go b/internal/contra/crypto/vectors_test.go| index c1d92a3..3af8e8a 100644 |
| --- a/internal/contra/crypto/vectors_test.go |
| +++ b/internal/contra/crypto/vectors_test.go |
| @@ -148,6 +148,24 @@ type vectorFile struct { |
| TagHex string `json:"tag_hex"` |
| } `json:"item"` |
| } `json:"aead"` |
| + Handoff struct { |
| + RecipientSeedHex string `json:"recipient_seed_hex"` |
| + RecipientScalarHex string `json:"recipient_scalar_hex"` |
| + RecipientPubHex string `json:"recipient_public_hex"` |
| + EphemeralSeedHex string `json:"ephemeral_seed_hex"` |
| + EphemeralScalarHex string `json:"ephemeral_scalar_hex"` |
| + EphemeralPubHex string `json:"ephemeral_public_hex"` |
| + SharedHex string `json:"shared_secret_hex"` |
| + KeyHex string `json:"key_hex"` |
| + AccountIDHex string `json:"account_id_hex"` |
| + ObjectType string `json:"object_type"` |
| + Version uint64 `json:"version"` |
| + 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:"handoff"` |
| AccountChain struct { |
| Passphrase string `json:"passphrase_utf8"` |
| SaltPWHex string `json:"salt_pw_hex"` |
| @@ -471,6 +489,102 @@ func TestAEADItem(t *testing.T) { |
| eq(t, "item tag", sealed[len(sealed)-16:], it.TagHex) |
| } |
| +// The handoff is the one place in the protocol where a secret travels through |
| +// the server, so this walks the whole chain against the generator: ECDH from |
| +// both sides, the derived key, the AAD, and the sealed bytes. |
| +func TestHandoffChain(t *testing.T) { |
| + v := load(t) |
| + h := v.Handoff |
| + |
| + // The keypairs are derived, so the seeds prove the vector's own inputs |
| + // were not quietly chosen to fit an answer. |
| + for _, c := range []struct{ name, seed, scalar, pub string }{ |
| + {"recipient", h.RecipientSeedHex, h.RecipientScalarHex, h.RecipientPubHex}, |
| + {"ephemeral", h.EphemeralSeedHex, h.EphemeralScalarHex, h.EphemeralPubHex}, |
| + } { |
| + key, err := DeriveP256(unhex(t, c.seed)) |
| + if err != nil { |
| + t.Fatalf("%s DeriveP256: %v", c.name, err) |
| + } |
| + eq(t, c.name+" scalar", key.Bytes(), c.scalar) |
| + eq(t, c.name+" public", key.PublicKey().Bytes(), c.pub) |
| + } |
| + |
| + // Both directions, because a one-sided check would pass on a primitive |
| + // that is not actually Diffie-Hellman. |
| + fwd, err := ECDH(unhex(t, h.EphemeralScalarHex), unhex(t, h.RecipientPubHex)) |
| + if err != nil { |
| + t.Fatalf("ECDH sender: %v", err) |
| + } |
| + eq(t, "shared secret (sender side)", fwd, h.SharedHex) |
| + |
| + back, err := ECDH(unhex(t, h.RecipientScalarHex), unhex(t, h.EphemeralPubHex)) |
| + if err != nil { |
| + t.Fatalf("ECDH recipient: %v", err) |
| + } |
| + eq(t, "shared secret (recipient side)", back, h.SharedHex) |
| + |
| + key, err := ECIESKey(unhex(t, h.EphemeralPubHex), unhex(t, h.RecipientPubHex), fwd) |
| + if err != nil { |
| + t.Fatalf("ECIESKey: %v", err) |
| + } |
| + eq(t, "handoff key", key, h.KeyHex) |
| + |
| + aad := AAD(unhex(t, h.AccountIDHex), h.ObjectType, unhex(t, h.RecipientPubHex), h.Version) |
| + eq(t, "handoff AAD", aad, h.AADHex) |
| + |
| + sealed, err := Seal(key, unhex(t, h.NonceHex), aad, unhex(t, h.PlainHex)) |
| + if err != nil { |
| + t.Fatalf("Seal: %v", err) |
| + } |
| + eq(t, "handoff ciphertext", sealed[:len(sealed)-16], h.CTHex) |
| + eq(t, "handoff tag", sealed[len(sealed)-16:], h.TagHex) |
| +} |
| + |
| +// SealTo mints its own ephemeral key and nonce, so it cannot be checked |
| +// against fixed bytes. What can be checked is the property that matters: the |
| +// envelope opens for the device it was sealed to, and for nobody else. |
| +func TestSealToRoundTripsAndIsBoundToOneDevice(t *testing.T) { |
| + v := load(t) |
| + h := v.Handoff |
| + recipientPub := unhex(t, h.RecipientPubHex) |
| + recipientScalar := unhex(t, h.RecipientScalarHex) |
| + accountKey := unhex(t, h.PlainHex) |
| + aad := AAD(unhex(t, h.AccountIDHex), ObjectHandoff, recipientPub, 1) |
| + |
| + envelope, err := SealTo(recipientPub, aad, accountKey) |
| + if err != nil { |
| + t.Fatalf("SealTo: %v", err) |
| + } |
| + if len(envelope) != PublicKeyLen+NonceLen+len(accountKey)+16 { |
| + t.Fatalf("envelope is %d bytes, want %d", |
| + len(envelope), PublicKeyLen+NonceLen+len(accountKey)+16) |
| + } |
| + |
| + got, err := OpenFrom(recipientScalar, recipientPub, aad, envelope) |
| + if err != nil { |
| + t.Fatalf("OpenFrom: %v", err) |
| + } |
| + eq(t, "handoff round trip", got, h.PlainHex) |
| + |
| + // A second device holds a different scalar. Without this the envelope |
| + // would be "sealed" in name only. |
| + other, err := DeriveP256(unhex(t, h.EphemeralSeedHex)) |
| + if err != nil { |
| + t.Fatalf("DeriveP256: %v", err) |
| + } |
| + if _, err := OpenFrom(other.Bytes(), other.PublicKey().Bytes(), aad, envelope); err == nil { |
| + t.Error("a different device opened an envelope that was not sealed to it") |
| + } |
| + |
| + // And the AAD names the recipient, so relaying the same envelope to a |
| + // second device fails even if that device's key were somehow the same. |
| + wrong := AAD(unhex(t, h.AccountIDHex), ObjectHandoff, other.PublicKey().Bytes(), 1) |
| + if _, err := OpenFrom(recipientScalar, recipientPub, wrong, envelope); err == nil { |
| + t.Error("an envelope opened under an AAD naming a different device") |
| + } |
| +} |
| + |
| // 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. |
diff --git a/internal/contra/static/crypto/ecies.js b/internal/contra/static/crypto/ecies.js| new file mode 100644 |
| index 0000000..7d2553a |
| --- /dev/null |
| +++ b/internal/contra/static/crypto/ecies.js |
| @@ -0,0 +1,132 @@ |
| +// Sealing to a public key: the device handoff, and later the drop box. |
| +// |
| +// This is the one place in CONTRA where a secret travels through the server. |
| +// The claim on the tin — "the server relays a blob it cannot open" — is only |
| +// true because the key is derived from an ECDH secret between the two devices |
| +// and never exists on the server at all. |
| +// |
| +// The Go twin is internal/contra/crypto/ecies.go and both are pinned by |
| +// vectors/contra-v1.json ("handoff"), whose generator checks OpenSSL against |
| +// RFC 5903 before it is allowed to produce a byte. |
| +// |
| +// envelope = ephemeral_public(65) || nonce(12) || ciphertext || tag(16) |
| +// key = HKDF(ikm = X(d*P), salt = eph_pub || recipient_pub, |
| +// info = "contra/v1/ecies", L = 32) |
| + |
| +import { concat, randomBytes } from "./bytes.js"; |
| +import { hkdf } from "./kdf.js"; |
| +import { publicKeyFor } from "./p256math.js"; |
| +import { open, seal } from "./sealed.js"; |
| + |
| +export const LABEL_ECIES = "contra/v1/ecies"; |
| +export const PUBLIC_KEY_LEN = 65; |
| +export const NONCE_LEN = 12; |
| + |
| +const b64url = (b) => btoa(String.fromCharCode(...b)) |
| + .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| + |
| +function b64urlToBytes(s) { |
| + const b64 = s.replace(/-/g, "+").replace(/_/g, "/"); |
| + const raw = atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, "=")); |
| + const out = new Uint8Array(raw.length); |
| + for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); |
| + return out; |
| +} |
| + |
| +function splitPoint(publicKey) { |
| + if (publicKey.length !== PUBLIC_KEY_LEN || publicKey[0] !== 0x04) { |
| + throw new Error("contra: not an uncompressed P-256 point"); |
| + } |
| + return { x: publicKey.slice(1, 33), y: publicKey.slice(33) }; |
| +} |
| + |
| +async function importPublic(publicKey) { |
| + return crypto.subtle.importKey( |
| + "raw", publicKey, { name: "ECDH", namedCurve: "P-256" }, false, []); |
| +} |
| + |
| +// Private ECDH keys are imported as a full JWK for the same reason p256.js |
| +// does it: a PKCS#8 with the public half omitted is a `DataError` on Safari. |
| +// The point is recomputed here rather than trusted from anywhere. |
| +async function importPrivate(scalar) { |
| + const { x, y } = publicKeyFor(scalar); |
| + const key = await crypto.subtle.importKey( |
| + "jwk", |
| + { |
| + kty: "EC", crv: "P-256", ext: true, |
| + d: b64url(scalar), x: b64url(x), y: b64url(y), |
| + }, |
| + { name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]); |
| + return { key, publicKey: concat(new Uint8Array([0x04]), x, y) }; |
| +} |
| + |
| +// ecdh is the raw primitive: X(d*P) at the full field width, Y discarded. |
| +// WebCrypto's deriveBits for ECDH returns exactly that, which is why the |
| +// vector checks it from both sides rather than trusting the name. |
| +export async function ecdh(scalar, peerPublic) { |
| + const { key } = await importPrivate(scalar); |
| + const bits = await crypto.subtle.deriveBits( |
| + { name: "ECDH", public: await importPublic(peerPublic) }, key, 256); |
| + return new Uint8Array(bits); |
| +} |
| + |
| +// eciesKey binds both public points into the salt, not just the shared |
| +// secret. The secret alone names a relationship rather than a message. |
| +export async function eciesKey(ephemeralPublic, recipientPublic, shared) { |
| + return hkdf(shared, concat(ephemeralPublic, recipientPublic), LABEL_ECIES, 32); |
| +} |
| + |
| +// sealTo mints a fresh ephemeral keypair per message, so the AEAD key is new |
| +// even though the recipient's key is not. |
| +// |
| +// The AAD is the caller's, and for the handoff it names the recipient's own |
| +// public key — so the server cannot relay one envelope to a second device. |
| +export async function sealTo(recipientPublic, additionalData, plaintext) { |
| + splitPoint(recipientPublic); |
| + const pair = await crypto.subtle.generateKey( |
| + { name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]); |
| + const jwk = await crypto.subtle.exportKey("jwk", pair.privateKey); |
| + const ephemeralPublic = concat( |
| + new Uint8Array([0x04]), b64urlToBytes(jwk.x), b64urlToBytes(jwk.y)); |
| + |
| + const bits = await crypto.subtle.deriveBits( |
| + { name: "ECDH", public: await importPublic(recipientPublic) }, |
| + pair.privateKey, 256); |
| + const key = await eciesKey(ephemeralPublic, recipientPublic, new Uint8Array(bits)); |
| + |
| + const nonce = randomBytes(NONCE_LEN); |
| + const sealed = await seal(key, nonce, additionalData, plaintext); |
| + return concat(ephemeralPublic, nonce, sealed); |
| +} |
| + |
| +// openFrom reverses sealTo. The recipient's own public key is passed rather |
| +// than recomputed because it is bound into the derivation: a caller holding |
| +// the wrong one should be told by a failing tag, not by a silent mismatch. |
| +export async function openFrom(scalar, recipientPublic, additionalData, envelope) { |
| + if (envelope.length < PUBLIC_KEY_LEN + NONCE_LEN) { |
| + throw new Error("contra: handoff envelope is too short"); |
| + } |
| + const ephemeralPublic = envelope.slice(0, PUBLIC_KEY_LEN); |
| + const nonce = envelope.slice(PUBLIC_KEY_LEN, PUBLIC_KEY_LEN + NONCE_LEN); |
| + const shared = await ecdh(scalar, ephemeralPublic); |
| + const key = await eciesKey(ephemeralPublic, recipientPublic, shared); |
| + return open(key, nonce, additionalData, envelope.slice(PUBLIC_KEY_LEN + NONCE_LEN)); |
| +} |
| + |
| +// newEphemeral is what the fresh device mints before it shows a code. The |
| +// scalar stays in this tab and never crosses the wire; only the point does. |
| +// |
| +// Rejection sampling rather than clamping, for the same reason deriveP256 |
| +// rejects: a uniform 32-byte string is not a uniform scalar mod n. A rejection |
| +// is a ~2^-32 event, so the second pass is essentially never taken. |
| +export async function newEphemeral() { |
| + for (let i = 0; i < 8; i++) { |
| + const scalar = randomBytes(32); |
| + try { |
| + return { scalar, publicKey: (await importPrivate(scalar)).publicKey }; |
| + } catch { |
| + // Out of [1, n-1]. Draw again. |
| + } |
| + } |
| + throw new Error("contra: no valid P-256 scalar in 8 draws"); |
| +} |
diff --git a/internal/contra/static/crypto/sealed.js b/internal/contra/static/crypto/sealed.js| index b30a9ca..361e1d8 100644 |
| --- a/internal/contra/static/crypto/sealed.js |
| +++ b/internal/contra/static/crypto/sealed.js |
| @@ -11,6 +11,7 @@ export const OBJECT_WRAP_RC = "wrap.rc"; |
| export const OBJECT_WRAP_PRF = "wrap.prf"; |
| export const OBJECT_VAULT_KEYSET = "vault.keyset"; |
| export const OBJECT_ITEM = "item"; |
| +export const OBJECT_HANDOFF = "handoff"; |
| // aad binds (scope, object type, object id, version) into every AEAD, so a |
| // malicious server cannot transplant a ciphertext between slots or between |
diff --git a/internal/contratest/jsmodules_test.go b/internal/contratest/jsmodules_test.go| index 711fc6c..50771e7 100644 |
| --- a/internal/contratest/jsmodules_test.go |
| +++ b/internal/contratest/jsmodules_test.go |
| @@ -31,6 +31,7 @@ var jsHighWater = map[string]int{ |
| "blake2b.js": 99, |
| "bytes.js": 61, |
| "code.js": 165, |
| + "ecies.js": 132, |
| "items.js": 97, |
| "kdf.js": 129, |
| "kit.js": 203, |
| @@ -41,7 +42,7 @@ var jsHighWater = map[string]int{ |
| "qr.js": 297, |
| "qrgf.js": 59, |
| "qrtables.js": 44, |
| - "sealed.js": 90, |
| + "sealed.js": 91, |
| "vaultkeys.js": 148, |
| // Page controllers. Thin on purpose: every decision that matters lives in |
diff --git a/test/ecies.test.mjs b/test/ecies.test.mjs| new file mode 100644 |
| index 0000000..4befdc2 |
| --- /dev/null |
| +++ b/test/ecies.test.mjs |
| @@ -0,0 +1,109 @@ |
| +// Sealing to a public key, against vectors/contra-v1.json ("handoff"). |
| +// |
| +// The device handoff is the one place a secret travels through the server, so |
| +// the JS half of it is pinned to the same bytes the Go half is — generated by |
| +// neither, in vectors/gen/gen.c against OpenSSL, itself checked against |
| +// RFC 5903 before it may emit anything. |
| + |
| +import { test } from "node:test"; |
| +import assert from "node:assert/strict"; |
| +import { readFileSync } from "node:fs"; |
| +import { fileURLToPath } from "node:url"; |
| +import { dirname, join } from "node:path"; |
| + |
| +const here = dirname(fileURLToPath(import.meta.url)); |
| +const mod = (name) => join(here, "..", "internal", "contra", "static", "crypto", name); |
| + |
| +const { toHex, fromHex } = await import(mod("bytes.js")); |
| +const { deriveP256 } = await import(mod("p256.js")); |
| +const { aad, OBJECT_HANDOFF } = await import(mod("sealed.js")); |
| +const ecies = await import(mod("ecies.js")); |
| + |
| +const V = JSON.parse(readFileSync(join(here, "..", "vectors", "contra-v1.json"), "utf8")); |
| +const H = V.handoff; |
| + |
| +test("handoff: both keypairs derive from their published seeds", async () => { |
| + for (const [name, seed, scalar, pub] of [ |
| + ["recipient", H.recipient_seed_hex, H.recipient_scalar_hex, H.recipient_public_hex], |
| + ["ephemeral", H.ephemeral_seed_hex, H.ephemeral_scalar_hex, H.ephemeral_public_hex], |
| + ]) { |
| + const key = await deriveP256(fromHex(seed)); |
| + assert.equal(toHex(key.scalar), scalar, `${name} scalar`); |
| + assert.equal(toHex(key.publicKey), pub, `${name} public key`); |
| + } |
| +}); |
| + |
| +// Both directions. A one-sided check would pass on something that is not |
| +// actually Diffie-Hellman. |
| +test("handoff: ECDH agrees from both sides", async () => { |
| + const fwd = await ecies.ecdh(fromHex(H.ephemeral_scalar_hex), fromHex(H.recipient_public_hex)); |
| + assert.equal(toHex(fwd), H.shared_secret_hex, "sender side"); |
| + |
| + const back = await ecies.ecdh(fromHex(H.recipient_scalar_hex), fromHex(H.ephemeral_public_hex)); |
| + assert.equal(toHex(back), H.shared_secret_hex, "recipient side"); |
| +}); |
| + |
| +test("handoff: the derived key, the AAD and the sealed bytes", async () => { |
| + const key = await ecies.eciesKey( |
| + fromHex(H.ephemeral_public_hex), |
| + fromHex(H.recipient_public_hex), |
| + fromHex(H.shared_secret_hex)); |
| + assert.equal(toHex(key), H.key_hex, "handoff key"); |
| + |
| + const a = aad(fromHex(H.account_id_hex), H.object_type, |
| + fromHex(H.recipient_public_hex), H.version); |
| + assert.equal(toHex(a), H.aad_hex, "handoff AAD"); |
| + |
| + const { seal } = await import(mod("sealed.js")); |
| + const sealed = await seal(key, fromHex(H.nonce_hex), a, fromHex(H.plaintext_hex)); |
| + assert.equal(toHex(sealed.slice(0, -16)), H.ciphertext_hex, "ciphertext"); |
| + assert.equal(toHex(sealed.slice(-16)), H.tag_hex, "tag"); |
| +}); |
| + |
| +// sealTo mints its own ephemeral key and nonce, so it cannot be checked |
| +// against fixed bytes. What can be checked is the property that matters. |
| +test("handoff: an envelope opens for its device and for nobody else", async () => { |
| + const recipientPublic = fromHex(H.recipient_public_hex); |
| + const recipientScalar = fromHex(H.recipient_scalar_hex); |
| + const accountKey = fromHex(H.plaintext_hex); |
| + const a = aad(fromHex(H.account_id_hex), OBJECT_HANDOFF, recipientPublic, 1); |
| + |
| + const envelope = await ecies.sealTo(recipientPublic, a, accountKey); |
| + assert.equal(envelope.length, 65 + 12 + accountKey.length + 16, "envelope length"); |
| + |
| + const got = await ecies.openFrom(recipientScalar, recipientPublic, a, envelope); |
| + assert.equal(toHex(got), H.plaintext_hex, "round trip"); |
| + |
| + const other = await deriveP256(fromHex(H.ephemeral_seed_hex)); |
| + await assert.rejects( |
| + () => ecies.openFrom(other.scalar, other.publicKey, a, envelope), |
| + "a different device must not open an envelope sealed to another"); |
| + |
| + // The AAD names the recipient, so the server cannot relay one envelope on. |
| + const wrong = aad(fromHex(H.account_id_hex), OBJECT_HANDOFF, other.publicKey, 1); |
| + await assert.rejects( |
| + () => ecies.openFrom(recipientScalar, recipientPublic, wrong, envelope), |
| + "an envelope must not open under an AAD naming a different device"); |
| +}); |
| + |
| +test("handoff: the Go and JS halves seal for each other", async () => { |
| + // A fresh ephemeral pair from the browser side, opened with the scalar it |
| + // kept — the exact shape the fresh device runs. |
| + const device = await ecies.newEphemeral(); |
| + assert.equal(device.publicKey.length, 65); |
| + assert.equal(device.publicKey[0], 0x04); |
| + |
| + const a = aad(fromHex(H.account_id_hex), OBJECT_HANDOFF, device.publicKey, 1); |
| + const envelope = await ecies.sealTo(device.publicKey, a, fromHex(H.plaintext_hex)); |
| + const got = await ecies.openFrom(device.scalar, device.publicKey, a, envelope); |
| + assert.equal(toHex(got), H.plaintext_hex); |
| +}); |
| + |
| +test("handoff: a truncated envelope is refused before any key work", async () => { |
| + const recipientPublic = fromHex(H.recipient_public_hex); |
| + const a = aad(fromHex(H.account_id_hex), OBJECT_HANDOFF, recipientPublic, 1); |
| + await assert.rejects( |
| + () => ecies.openFrom(fromHex(H.recipient_scalar_hex), recipientPublic, a, |
| + new Uint8Array(40)), |
| + /too short/); |
| +}); |
diff --git a/vectors/contra-v1.json b/vectors/contra-v1.json| index a94af55..10e69c9 100644 |
| --- a/vectors/contra-v1.json |
| +++ b/vectors/contra-v1.json |
| @@ -210,6 +210,28 @@ |
| "tag_hex": "8c033da48e3a51c5a8e7618c95cba27f" |
| } |
| }, |
| + "handoff": { |
| + "note": "DESIGN.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", |
| + "shared_secret": "X(d*P), big-endian at the field width; Y discarded, per SEC 1 3.3.1", |
| + "key": "HKDF-SHA-256(ikm=shared_secret, salt=ephemeral_public || recipient_public, info=\"contra/v1/ecies\", L=32)", |
| + "envelope": "ephemeral_public(65) || nonce(12) || ciphertext || tag(16)", |
| + "recipient_seed_hex": "9b99cffdf8c8336ee1052a5d23d8c6a89233b6f4b8dce3abb67467797ff5f47c", |
| + "recipient_scalar_hex": "7066a8a9a92ab829b8410497f8832950dd2c873d27c8e8719fd07b1bdb2c2689", |
| + "recipient_public_hex": "04a3bd4385af0b7002d753e994b46b670b515961d7c937be9bd67bb32bfc195fbd00893a7972b680c821681c479e54bfc36b0bff3555883d36d836914bc942eef3", |
| + "ephemeral_seed_hex": "80d827ebf9b6cbd89ebdc4e883f1f6604e55ee6639ff0523c89cd2ce4cbfd069", |
| + "ephemeral_scalar_hex": "29bb73bd46d37937e6aec53e555e8121248dd20ca2276c7a62a80e1294fe0f12", |
| + "ephemeral_public_hex": "04f32e19a10b4812c4b681abf4e0f695dfcd7c409de8bc3432f2678c6bba111cec0bcdd4499e5b0dbdf422e122ee3aa8de1bbbae9601cb8e724ff29d480c990da4", |
| + "shared_secret_hex": "2c5a4aaf13ca4aceeeea13a4cb51992086b61d2a00fcd3c33e0c01baf69d9725", |
| + "key_hex": "f7e866f04455d3ad3387fa20096c3d4a4762e16893390f7ce6b862c9857a34b9", |
| + "account_id_hex": "b22c6a5eae1be9d6a773f474fd0532b0", |
| + "object_type": "handoff", |
| + "version": 1, |
| + "nonce_hex": "f3d0c570acc6bb78bdb8f27b", |
| + "aad_hex": "636f6e7472612f76312f6161640000000010b22c6a5eae1be9d6a773f474fd0532b00000000768616e646f66660000004104a3bd4385af0b7002d753e994b46b670b515961d7c937be9bd67bb32bfc195fbd00893a7972b680c821681c479e54bfc36b0bff3555883d36d836914bc942eef30000000000000001", |
| + "plaintext_hex": "f60fb281e8c72f6725add4cbd40e7b08", |
| + "ciphertext_hex": "37388e3ef836f43004b123b0322071d9", |
| + "tag_hex": "e3855f79299f4270d9f5fb0572171be8" |
| + }, |
| "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", |
diff --git a/vectors/gen/gen.c b/vectors/gen/gen.c| index 83b73bf..537475f 100644 |
| --- a/vectors/gen/gen.c |
| +++ b/vectors/gen/gen.c |
| @@ -58,6 +58,20 @@ static const char *hex(const unsigned char *b, size_t n) { |
| 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"); |
| @@ -220,6 +234,62 @@ static void p256_from_seed(const unsigned char seed[32], struct p256 *r) { |
| 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 */ |
| /* |
| @@ -520,6 +590,34 @@ static void check_anchors(void) { |
| 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 */ |
| @@ -581,8 +679,11 @@ int main(int argc, char **argv) { |
| 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; |
| + 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]; |
| @@ -604,6 +705,9 @@ int main(int argc, char **argv) { |
| 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 |
| @@ -647,6 +751,32 @@ int main(int argc, char **argv) { |
| 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++) |
| @@ -951,6 +1081,37 @@ int main(int argc, char **argv) { |
| field("tag_hex", "\"%s\"", hex(item_tag, 16)); |
| printf("\n }\n },\n"); |
| + printf(" \"handoff\": {\n"); |
| + printf(" \"note\": \"DESIGN.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 DESIGN.md's 'The shape', end to end, " |
| "so a new implementation can bisect where it diverges\",\n"); |