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: the device handoff, and later the drop box.
2//
3// This is the one place in CONTRA where a secret travels through the server.
4// The claim on the tin — "the server relays a blob it cannot open" — is only
5// true because the key is derived from an ECDH secret between the two devices
6// and never exists on the server at all.
7//
8// The Go twin is internal/contra/crypto/ecies.go and both are pinned by
9// vectors/contra-v1.json ("handoff"), whose generator checks OpenSSL against
10// RFC 5903 before it is allowed to produce a byte.
11//
12// envelope = ephemeral_public(65) || nonce(12) || ciphertext || tag(16)
13// key = HKDF(ikm = X(d*P), salt = eph_pub || recipient_pub,
14// info = "contra/v1/ecies", L = 32)
15
16import { concat, randomBytes } from "./bytes.js";
17import { hkdf } from "./kdf.js";
18import { publicKeyFor } from "./p256math.js";
19import { open, seal } from "./sealed.js";
20
21export const LABEL_ECIES = "contra/v1/ecies";
22export const PUBLIC_KEY_LEN = 65;
23export const NONCE_LEN = 12;
24
25const b64url = (b) => btoa(String.fromCharCode(...b))
26 .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
27
28function b64urlToBytes(s) {
29 const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
30 const raw = atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, "="));
31 const out = new Uint8Array(raw.length);
32 for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
33 return out;
34}
35
36function splitPoint(publicKey) {
37 if (publicKey.length !== PUBLIC_KEY_LEN || publicKey[0] !== 0x04) {
38 throw new Error("contra: not an uncompressed P-256 point");
39 }
40 return { x: publicKey.slice(1, 33), y: publicKey.slice(33) };
41}
42
43async function importPublic(publicKey) {
44 return crypto.subtle.importKey(
45 "raw", publicKey, { name: "ECDH", namedCurve: "P-256" }, false, []);
46}
47
48// Private ECDH keys are imported as a full JWK for the same reason p256.js
49// does it: a PKCS#8 with the public half omitted is a `DataError` on Safari.
50// The point is recomputed here rather than trusted from anywhere.
51async function importPrivate(scalar) {
52 const { x, y } = publicKeyFor(scalar);
53 const key = await crypto.subtle.importKey(
54 "jwk",
55 {
56 kty: "EC", crv: "P-256", ext: true,
57 d: b64url(scalar), x: b64url(x), y: b64url(y),
58 },
59 { name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
60 return { key, publicKey: concat(new Uint8Array([0x04]), x, y) };
61}
62
63// ecdh is the raw primitive: X(d*P) at the full field width, Y discarded.
64// WebCrypto's deriveBits for ECDH returns exactly that, which is why the
65// vector checks it from both sides rather than trusting the name.
66export async function ecdh(scalar, peerPublic) {
67 const { key } = await importPrivate(scalar);
68 const bits = await crypto.subtle.deriveBits(
69 { name: "ECDH", public: await importPublic(peerPublic) }, key, 256);
70 return new Uint8Array(bits);
71}
72
73// eciesKey binds both public points into the salt, not just the shared
74// secret. The secret alone names a relationship rather than a message.
75export async function eciesKey(ephemeralPublic, recipientPublic, shared) {
76 return hkdf(shared, concat(ephemeralPublic, recipientPublic), LABEL_ECIES, 32);
77}
78
79// sealTo mints a fresh ephemeral keypair per message, so the AEAD key is new
80// even though the recipient's key is not.
81//
82// The AAD is the caller's, and for the handoff it names the recipient's own
83// public key — so the server cannot relay one envelope to a second device.
84export async function sealTo(recipientPublic, additionalData, plaintext) {
85 splitPoint(recipientPublic);
86 const pair = await crypto.subtle.generateKey(
87 { name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
88 const jwk = await crypto.subtle.exportKey("jwk", pair.privateKey);
89 const ephemeralPublic = concat(
90 new Uint8Array([0x04]), b64urlToBytes(jwk.x), b64urlToBytes(jwk.y));
91
92 const bits = await crypto.subtle.deriveBits(
93 { name: "ECDH", public: await importPublic(recipientPublic) },
94 pair.privateKey, 256);
95 const key = await eciesKey(ephemeralPublic, recipientPublic, new Uint8Array(bits));
96
97 const nonce = randomBytes(NONCE_LEN);
98 const sealed = await seal(key, nonce, additionalData, plaintext);
99 return concat(ephemeralPublic, nonce, sealed);
100}
101
102// openFrom reverses sealTo. The recipient's own public key is passed rather
103// than recomputed because it is bound into the derivation: a caller holding
104// the wrong one should be told by a failing tag, not by a silent mismatch.
105export async function openFrom(scalar, recipientPublic, additionalData, envelope) {
106 if (envelope.length < PUBLIC_KEY_LEN + NONCE_LEN) {
107 throw new Error("contra: handoff envelope is too short");
108 }
109 const ephemeralPublic = envelope.slice(0, PUBLIC_KEY_LEN);
110 const nonce = envelope.slice(PUBLIC_KEY_LEN, PUBLIC_KEY_LEN + NONCE_LEN);
111 const shared = await ecdh(scalar, ephemeralPublic);
112 const key = await eciesKey(ephemeralPublic, recipientPublic, shared);
113 return open(key, nonce, additionalData, envelope.slice(PUBLIC_KEY_LEN + NONCE_LEN));
114}
115
116// newEphemeral is what the fresh device mints before it shows a code. The
117// scalar stays in this tab and never crosses the wire; only the point does.
118//
119// Rejection sampling rather than clamping, for the same reason deriveP256
120// rejects: a uniform 32-byte string is not a uniform scalar mod n. A rejection
121// is a ~2^-32 event, so the second pass is essentially never taken.
122export async function newEphemeral() {
123 for (let i = 0; i < 8; i++) {
124 const scalar = randomBytes(32);
125 try {
126 return { scalar, publicKey: (await importPrivate(scalar)).publicKey };
127 } catch {
128 // Out of [1, n-1]. Draw again.
129 }
130 }
131 throw new Error("contra: no valid P-256 scalar in 8 draws");
132}
133