carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

1// Sealing to a public key, against vectors/contra-v1.json ("handoff").
2//
3// The device handoff is the one place a secret travels through the server, so
4// the JS half of it is pinned to the same bytes the Go half is — generated by
5// neither, in vectors/gen/gen.c against OpenSSL, itself checked against
6// RFC 5903 before it may emit anything.
7
8import { test } from "node:test";
9import assert from "node:assert/strict";
10import { readFileSync } from "node:fs";
11import { fileURLToPath } from "node:url";
12import { dirname, join } from "node:path";
13
14const here = dirname(fileURLToPath(import.meta.url));
15const mod = (name) => join(here, "..", "internal", "contra", "static", "crypto", name);
16
17const { toHex, fromHex } = await import(mod("bytes.js"));
18const { deriveP256 } = await import(mod("p256.js"));
19const { aad, OBJECT_HANDOFF } = await import(mod("sealed.js"));
20const ecies = await import(mod("ecies.js"));
21
22const V = JSON.parse(readFileSync(join(here, "..", "vectors", "contra-v1.json"), "utf8"));
23const H = V.handoff;
24
25test("handoff: both keypairs derive from their published seeds", async () => {
26 for (const [name, seed, scalar, pub] of [
27 ["recipient", H.recipient_seed_hex, H.recipient_scalar_hex, H.recipient_public_hex],
28 ["ephemeral", H.ephemeral_seed_hex, H.ephemeral_scalar_hex, H.ephemeral_public_hex],
29 ]) {
30 const key = await deriveP256(fromHex(seed));
31 assert.equal(toHex(key.scalar), scalar, `${name} scalar`);
32 assert.equal(toHex(key.publicKey), pub, `${name} public key`);
33 }
34});
35
36// Both directions. A one-sided check would pass on something that is not
37// actually Diffie-Hellman.
38test("handoff: ECDH agrees from both sides", async () => {
39 const fwd = await ecies.ecdh(fromHex(H.ephemeral_scalar_hex), fromHex(H.recipient_public_hex));
40 assert.equal(toHex(fwd), H.shared_secret_hex, "sender side");
41
42 const back = await ecies.ecdh(fromHex(H.recipient_scalar_hex), fromHex(H.ephemeral_public_hex));
43 assert.equal(toHex(back), H.shared_secret_hex, "recipient side");
44});
45
46test("handoff: the derived key, the AAD and the sealed bytes", async () => {
47 const key = await ecies.eciesKey(
48 fromHex(H.ephemeral_public_hex),
49 fromHex(H.recipient_public_hex),
50 fromHex(H.shared_secret_hex));
51 assert.equal(toHex(key), H.key_hex, "handoff key");
52
53 const a = aad(fromHex(H.account_id_hex), H.object_type,
54 fromHex(H.recipient_public_hex), H.version);
55 assert.equal(toHex(a), H.aad_hex, "handoff AAD");
56
57 const { seal } = await import(mod("sealed.js"));
58 const sealed = await seal(key, fromHex(H.nonce_hex), a, fromHex(H.plaintext_hex));
59 assert.equal(toHex(sealed.slice(0, -16)), H.ciphertext_hex, "ciphertext");
60 assert.equal(toHex(sealed.slice(-16)), H.tag_hex, "tag");
61});
62
63// sealTo mints its own ephemeral key and nonce, so it cannot be checked
64// against fixed bytes. What can be checked is the property that matters.
65test("handoff: an envelope opens for its device and for nobody else", async () => {
66 const recipientPublic = fromHex(H.recipient_public_hex);
67 const recipientScalar = fromHex(H.recipient_scalar_hex);
68 const accountKey = fromHex(H.plaintext_hex);
69 const a = aad(fromHex(H.account_id_hex), OBJECT_HANDOFF, recipientPublic, 1);
70
71 const envelope = await ecies.sealTo(recipientPublic, a, accountKey);
72 assert.equal(envelope.length, 65 + 12 + accountKey.length + 16, "envelope length");
73
74 const got = await ecies.openFrom(recipientScalar, recipientPublic, a, envelope);
75 assert.equal(toHex(got), H.plaintext_hex, "round trip");
76
77 const other = await deriveP256(fromHex(H.ephemeral_seed_hex));
78 await assert.rejects(
79 () => ecies.openFrom(other.scalar, other.publicKey, a, envelope),
80 "a different device must not open an envelope sealed to another");
81
82 // The AAD names the recipient, so the server cannot relay one envelope on.
83 const wrong = aad(fromHex(H.account_id_hex), OBJECT_HANDOFF, other.publicKey, 1);
84 await assert.rejects(
85 () => ecies.openFrom(recipientScalar, recipientPublic, wrong, envelope),
86 "an envelope must not open under an AAD naming a different device");
87});
88
89test("handoff: the Go and JS halves seal for each other", async () => {
90 // A fresh ephemeral pair from the browser side, opened with the scalar it
91 // kept — the exact shape the fresh device runs.
92 const device = await ecies.newEphemeral();
93 assert.equal(device.publicKey.length, 65);
94 assert.equal(device.publicKey[0], 0x04);
95
96 const a = aad(fromHex(H.account_id_hex), OBJECT_HANDOFF, device.publicKey, 1);
97 const envelope = await ecies.sealTo(device.publicKey, a, fromHex(H.plaintext_hex));
98 const got = await ecies.openFrom(device.scalar, device.publicKey, a, envelope);
99 assert.equal(toHex(got), H.plaintext_hex);
100});
101
102test("handoff: a truncated envelope is refused before any key work", async () => {
103 const recipientPublic = fromHex(H.recipient_public_hex);
104 const a = aad(fromHex(H.account_id_hex), OBJECT_HANDOFF, recipientPublic, 1);
105 await assert.rejects(
106 () => ecies.openFrom(fromHex(H.recipient_scalar_hex), recipientPublic, a,
107 new Uint8Array(40)),
108 /too short/);
109});
110