cautpartener / client / src / lib / wrapbox.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
/**
 * 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;
  }
}

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