carlos / contra Public

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>
Paul Campbell pushed by paul@keymail.dev 0f00fc074bdeb20980bae79ac13271a9bd5db11b parent 11d8c3b
10 files changed, +652 −2
  • internal/contra/crypto/aead.go +1 −0
  • internal/contra/crypto/ecies.go +108 −0
  • internal/contra/crypto/kdf.go +1 −0
  • internal/contra/crypto/vectors_test.go +114 −0
  • internal/contra/static/crypto/ecies.js +132 −0
  • internal/contra/static/crypto/sealed.js +1 −0
  • internal/contratest/jsmodules_test.go +2 −1
  • test/ecies.test.mjs +109 −0
  • vectors/contra-v1.json +22 −0
  • vectors/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");