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
115
116
117
118
119
120 | /**
* Persistent cryptographic identity (app-agnostic).
*
* An identity is a single 32-byte Ed25519 seed, encoded base64url (43 chars) —
* short enough to live in a password manager. The keypair is derived from it
* on every boot. Each session, the identity signs a binding assertion
* "this room + this session peerId is mine"; peers verify it and render a
* fingerprint of the public key. This is what makes names more than claims:
* room members share the room keys, so only a signature can distinguish WHO
* inside the room is talking (and it defeats even a malicious relay colluding
* with a member to replay someone's peerId).
*/
import { fromB64url, randomBytes, toB64, toB64url, type Bytes } from "./crypto";
const te = new TextEncoder();
// PKCS8 wrapper for a raw Ed25519 seed (RFC 8410 structure, fixed prefix).
const PKCS8_PREFIX = new Uint8Array([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
]);
export interface Fingerprint {
emoji: string; // 4 emoji ≈ casual comparison
hex: string; // full SHA-256 of the public key, for careful comparison
}
export class Identity {
readonly publicKeyB64: string;
#priv: CryptoKey;
#fp: Fingerprint;
private constructor(priv: CryptoKey, pubB64: string, fp: Fingerprint) {
this.#priv = priv;
this.publicKeyB64 = pubB64;
this.#fp = fp;
}
get fingerprint(): Fingerprint {
return this.#fp;
}
/** Load from a seed string (43-char base64url). Throws on malformed seeds. */
static async fromSeed(seedB64url: string): Promise<Identity> {
const seed = fromB64url(seedB64url);
if (seed.length !== 32) throw new Error("identity seed must be 32 bytes");
const pkcs8 = new Uint8Array(PKCS8_PREFIX.length + 32);
pkcs8.set(PKCS8_PREFIX);
pkcs8.set(seed, PKCS8_PREFIX.length);
// extractable=true only to let the UA compute the public half for JWK export
const tmp = await crypto.subtle.importKey("pkcs8", pkcs8 as BufferSource, "Ed25519", true, ["sign"]);
const jwk = await crypto.subtle.exportKey("jwk", tmp);
if (!jwk.x) throw new Error("could not derive public key");
const priv = await crypto.subtle.importKey("pkcs8", pkcs8 as BufferSource, "Ed25519", false, ["sign"]);
const pubB64 = jwk.x; // base64url raw public key
return new Identity(priv, pubB64, await fingerprintOf(pubB64));
}
static newSeed(): string {
return toB64url(randomBytes(32));
}
/** Sign the session binding: proves this room-session peerId belongs to this identity. */
async assert(appSalt: string, roomId: string, peerId: string): Promise<string> {
const sig = await crypto.subtle.sign("Ed25519", this.#priv, bindingBytes(appSalt, roomId, peerId));
return toB64(new Uint8Array(sig));
}
}
/** Verify a peer's binding assertion; returns the fingerprint or null. */
export async function verifyAssertion(
appSalt: string,
roomId: string,
peerId: string,
publicKeyB64: string,
sigB64: string,
): Promise<Fingerprint | null> {
try {
const pub = await crypto.subtle.importKey("raw", fromB64url(publicKeyB64) as BufferSource, "Ed25519", false, [
"verify",
]);
const ok = await crypto.subtle.verify(
"Ed25519",
pub,
fromB64url(sigB64.replaceAll("+", "-").replaceAll("/", "_")) as BufferSource,
bindingBytes(appSalt, roomId, peerId),
);
return ok ? await fingerprintOf(publicKeyB64) : null;
} catch {
return null;
}
}
function bindingBytes(appSalt: string, roomId: string, peerId: string): Bytes {
return te.encode(`sueta-id|v1|${appSalt}|${roomId}|${peerId}`) as Bytes;
}
// 64 visually distinct emoji → 4 × 6 bits = first 24 bits of SHA-256(pub)
// prettier-ignore
const EMOJI64 = [
"🐢","🦊","🐼","🦁","🐸","🐙","🦉","🐝","🐬","🦄","🐞","🦋","🌵","🌲","🍁","🍄",
"🌻","🌙","⭐","🔥","🌈","⚡","❄️","🌊","🍎","🍋","🍇","🍓","🥑","🌽","🥕","🍩",
"🍕","🥨","🧀","🍿","☕","🍵","🧊","🎈","🎲","🎯","🎸","🎺","🥁","🚀","🚲","⛵",
"🚂","🗿","🗝️","🔔","🧲","🧭","⏳","📚","🖍️","📎","✂️","🔍","💎","🛡️","⚙️","🎁",
];
export async function fingerprintOf(publicKeyB64: string): Promise<Fingerprint> {
const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", fromB64url(publicKeyB64) as BufferSource));
const bits = (hash[0] << 16) | (hash[1] << 8) | hash[2];
const emoji = [18, 12, 6, 0].map((s) => EMOJI64[(bits >> s) & 63]).join("");
const hex = [...hash].map((b) => b.toString(16).padStart(2, "0")).join("");
return { emoji, hex };
}
export function isEd25519Supported(): Promise<boolean> {
return crypto.subtle
.generateKey("Ed25519", false, ["sign", "verify"])
.then(() => true)
.catch(() => false);
}
|