sueta / client / src / lib / identity.ts
  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);
}