1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114 | /**
* X25519 encryption identity + sealed-box "wraps" for the access layer.
*
* Every Ed25519 identity deterministically owns an X25519 keypair:
* xSeed = HKDF-SHA256(ikm = identity seed, salt = UTF8(appSalt), info = "id-x25519|v1")
* so the same seed yields the same encryption identity on every device —
* nothing new is stored. The X25519 pub is certified by an Ed25519 signature
* (domain-separated, not replayable across apps), verified before anyone
* seals a grant to it.
*
* Curve arithmetic uses @noble/curves — already in the dependency tree (it IS
* the X25519 inside libp2p-noise), so this adds zero bundle bytes and needs
* no WebCrypto-X25519 feature detection. AEAD stays WebCrypto AES-GCM.
*
* wrap(to): HPKE-base-mode shaped sealed box —
* (ephPriv, ephPub) fresh; ss = X25519(ephPriv, toXPub)
* kW = HKDF(ss, salt=appSalt, info="wrap|v1|"+roomId+"|"+toIdPub+"|"+ephPub)
* ct = AES-GCM(kW, iv, payload, AAD "v1|"+roomId+"|wrap|"+toIdPub)
* Sender authenticity needs no extra signature: a wrap only exists inside a
* log entry bound to its author by the hardened access controller.
*/
import { x25519 } from "@noble/curves/ed25519";
import { fromB64url, randomBytes, toB64, toB64url, fromB64, type Bytes } from "./crypto";
import { Identity, verifyRaw } from "./identity";
import type { Wrap } from "./protocol";
const te = new TextEncoder();
const utf8 = (s: string): Bytes => te.encode(s) as Bytes;
export interface XKeyPair {
priv: Bytes;
pubB64: string;
}
/** Derive the deterministic X25519 keypair from the 32-byte identity seed. */
export async function deriveXKeyPair(identitySeedB64url: string, appSalt: string): Promise<XKeyPair> {
const seed = fromB64url(identitySeedB64url);
const ikm = await crypto.subtle.importKey("raw", seed as BufferSource, "HKDF", false, ["deriveBits"]);
const bits = await crypto.subtle.deriveBits(
{ name: "HKDF", hash: "SHA-256", salt: utf8(appSalt) as BufferSource, info: utf8("id-x25519|v1") as BufferSource },
ikm,
256,
);
const priv = new Uint8Array(bits) as Bytes;
return { priv, pubB64: toB64url(x25519.getPublicKey(priv) as Bytes) };
}
const certBytes = (appSalt: string, idPub: string, xPub: string): Bytes =>
utf8(`board-x25519|v1|${appSalt}|${idPub}|${xPub}`);
/** Sign our X25519 pub with the Ed25519 identity (the "cert"). */
export async function signXCert(identity: Identity, appSalt: string, xPub: string): Promise<string> {
return toB64url(await identity.signRaw(certBytes(appSalt, identity.publicKeyB64, xPub)));
}
/** Verify a peer's cert binds xPub to their Ed25519 identity. */
export function verifyXCert(appSalt: string, idPub: string, xPub: string, xSigB64url: string): Promise<boolean> {
return verifyRaw(idPub, xSigB64url, certBytes(appSalt, idPub, xPub));
}
async function wrapKey(ss: Bytes, appSalt: string, roomId: string, toIdPub: string, ephPubB64: string): Promise<CryptoKey> {
const ikm = await crypto.subtle.importKey("raw", ss as BufferSource, "HKDF", false, ["deriveKey"]);
return crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: utf8(appSalt) as BufferSource,
info: utf8(`wrap|v1|${roomId}|${toIdPub}|${ephPubB64}`) as BufferSource,
},
ikm,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
/** Seal `payload` to an identity's certified X25519 pub. */
export async function wrapTo(
appSalt: string,
roomId: string,
toIdPub: string,
toXPubB64: string,
payload: Bytes,
): Promise<Wrap> {
const ephPriv = randomBytes(32);
const ephPub = x25519.getPublicKey(ephPriv) as Bytes;
const ephPubB64 = toB64url(ephPub);
const ss = x25519.getSharedSecret(ephPriv, fromB64url(toXPubB64)) as Bytes;
const key = await wrapKey(ss, appSalt, roomId, toIdPub, ephPubB64);
const iv = randomBytes(12);
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: iv as BufferSource, additionalData: utf8(`v1|${roomId}|wrap|${toIdPub}`) as BufferSource },
key,
payload as BufferSource,
);
return { to: toIdPub, toX: toXPubB64, ephPub: ephPubB64, iv: toB64(iv), ct: toB64(new Uint8Array(ct) as Bytes) };
}
/** Open a wrap addressed to us; null on any failure (wrong key, tampered). */
export async function unwrap(appSalt: string, roomId: string, w: Wrap, myX: XKeyPair): Promise<Bytes | null> {
try {
const ss = x25519.getSharedSecret(myX.priv, fromB64url(w.ephPub)) as Bytes;
const key = await wrapKey(ss, appSalt, roomId, w.to, w.ephPub);
const pt = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: fromB64(w.iv) as BufferSource, additionalData: utf8(`v1|${roomId}|wrap|${w.to}`) as BufferSource },
key,
fromB64(w.ct) as BufferSource,
);
return new Uint8Array(pt) as Bytes;
} catch {
return null;
}
}
|