cautpartener / 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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
/**
 * 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));
  }

  /** Raw Ed25519 signature over arbitrary bytes (OrbitDB identity provider). */
  async signRaw(data: Bytes): Promise<Bytes> {
    const sig = await crypto.subtle.sign("Ed25519", this.#priv, data as BufferSource);
    return new Uint8Array(sig);
  }
}

/** Verify a raw Ed25519 signature against a base64url public key. */
export async function verifyRaw(publicKeyB64: string, sigB64url: string, data: Bytes): Promise<boolean> {
  try {
    const pub = await crypto.subtle.importKey("raw", fromB64url(publicKeyB64) as BufferSource, "Ed25519", false, [
      "verify",
    ]);
    return await crypto.subtle.verify("Ed25519", pub, fromB64url(sigB64url) as BufferSource, data as BufferSource);
  } catch {
    return false;
  }
}

/** 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|v2|${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);
}

static mirror of HEAD ยท about ยท clone: git clone https://cod-sursa.cautpartener.ro/cautpartener.git