Security

How the encryption works

Every key Shuriken makes, what each one seals and what our cloud holds of it, with the limits we know about. The code that does it is at the end of the page, read from our repository when this page was built.

The short version#

Four things to know before the detail.

  • Your password never leaves your device. It unlocks a vault key that one of your devices made at random, and our cloud stores that key only locked.
  • Every other key is made from the vault key or made on one device. Our cloud holds public halves, signatures and ciphertext.
  • Your apps and your computers talk through our relay with keys made fresh for each connection. The relay forwards sealed frames it cannot open.
  • When you remove a device with your password, your other devices get a new vault key and the removed one does not.

Your password and the account keys#

One password, one random vault key, and four keys made from it.

When you sign in, your device turns your password into a password key: PBKDF2-SHA256 over the password and a 16-byte salt, 600,000 rounds. A vault that has not moved to 600,000 yet uses 310,000; it moves the next time one of your apps holds the password.

HKDF-SHA-512 of the password key gives the sign-in key. Your app sends that instead of the password, and our cloud keeps only a PBKDF2-SHA256 hash of it (100,000 rounds). A sign-in with an email code, or with Google or Apple, proves who you are to our cloud but opens no vault: that still takes your password.

The vault key is 32 random bytes made on the first device that needed one. Our cloud stores it wrapped with AES-256-GCM under your password key, so a new device signs in and unwraps it itself. After that the desktop app keeps it in its own encrypted store, whose key sits in the Mac's Keychain, Windows' DPAPI or Linux's Secret Service; a phone keeps it in the iOS Keychain or the Android Keystore; a browser keeps it in the open tab only.

Everything in your vault (source sign-ins, API keys, MCP sign-ins) is sealed with AES-256-GCM under the vault key, with a fresh 12-byte IV each time. Inside the ciphertext each item names its own id, its kind and a revision number, so our cloud cannot move one item into another's place, and a device refuses an older revision than one it has already opened.

From the vault key, HKDF-SHA-512 with a separate label each makes four more keys. Every device that holds the vault key makes the same four, so none needs a backup of its own, and all four change when the vault key changes.

KeyKindWhat it doesWhat our cloud holds
Account keyEd25519Signs your devices' keys, each relay handshake and each key rotationThe public half and the signatures
Inbox keyX25519Opens a sign-in another Shuriken account sealed to youThe public half, signed by the account key
Documents keyXSalsa20-Poly1305Seals the few documents your computers read tooNothing
Pictures keyAES-256-GCMSeals your profile picture and project picturesNothing, apart from copies sealed to a browser's own key
Where each key comes from Figure 1. Your password makes the password key, which makes the sign-in key and unlocks the vault key. Four more keys are made from the vault key. Under each, what our cloud holds of it. Your password Typed on your device Our cloud: never sent Password key PBKDF2-SHA256, 600,000 rounds Our cloud: the salt and round count Sign-in key HKDF-SHA-512 of the password key Our cloud: a PBKDF2 hash of it Vault key Random, unlocked by the password key Our cloud: locked under the password key Account key Ed25519, HKDF-SHA-512 of the vault key Our cloud: the public half Inbox key X25519, HKDF-SHA-512 of the vault key Our cloud: the public half Documents key HKDF-SHA-512 of the vault key Our cloud: nothing Pictures key HKDF-SHA-512 of the vault key Our cloud: nothing
Figure 1. Your password makes the password key, which makes the sign-in key and unlocks the vault key. Four more keys are made from the vault key. Under each, what our cloud holds of it.

Device keys#

Keys made on one device that never leave it. The account key vouches for each.

Some keys are not made from the vault key. One device makes them and keeps the secret half. Your account key signs the public half, and another device checks that signature before it uses a key our cloud hands it. A key the cloud swaps in has no valid signature, so it gets nothing.

  • Computer key (X25519): made at random by each computer or server that runs agents, and kept in a file only the user account it runs as can read. Every connection to that computer is sealed to it.
  • Push key (P-256): made on each phone. Pushes from your computers are sealed to it. A browser has none: it gets only the alerts our cloud writes itself, such as a device going offline, so those are not end-to-end encrypted.
  • Share key (X25519): one per device with the app. When you remove another device, the new vault key reaches this one sealed to it.
  • A browser's picture key (P-256): made in the browser so that it cannot be exported. The pictures key is sealed to it, so a later tab shows your pictures without the vault key. A browser you said is not your computer makes none.

The desktop app signs its own computer's key. A server you add with the install command proves its key with a join secret: 32 random bytes the app puts in the command and never sends to us. The server publishes its key with an HMAC under that secret, and the app posts your account key with one too, so each side knows the other's key came from you. A server you add by typing its code instead is never signed: it shows as Not verified and your apps send it nothing (see the limits below).

The relay handshake#

How an app and a computer make keys our relay cannot make.

Your phone, a browser or the desktop app reaches a computer through our relay, or straight across your own network when it can. Both routes run the same handshake, shaped like Noise NK: the app already knows the computer's key, signed by your account key, and each side brings a fresh one-time key.

  1. Hello. The app makes a new X25519 key pair and sends the public half, with a proof: your account key's signature over the computer's key and this handshake's key. Once a computer has said it reads one, the proof also carries the time it was signed, and a proof more than a day from the computer's clock is refused. A computer admits an app as you only when the proof checks against the account key it pinned.
  2. Ready. The computer answers with a one-time key of its own.
  3. Session keys. Each side runs HKDF-SHA-512 over two X25519 results, the app's one-time key with the computer's key and the two one-time keys with each other, salted with all three public keys. Out come two keys, one for each direction.
  4. Frames. Every message is sealed with XSalsa20-Poly1305. Its nonce is the sender's counter and the receiver accepts only the next number, so a frame that is replayed, dropped, reordered or sent back fails and the connection closes. Frames are padded inside the encryption to a multiple of 256 bytes up to 16 KiB, then of 4 KiB.

Once both one-time keys are gone, the session is forward secret: someone who records the traffic and later steals the computer's key still opens none of its session frames. A computer also remembers every handshake it accepted for 30 days, in a file that survives a restart, so a recorded connection played into a new socket is refused (an app from 5.0.0 or earlier has a gap here, under Known limits).

One connection through our relay Figure 2. The app and the computer each make session keys from one-time keys. The relay forwards the handshake and the sealed frames, and cannot make the keys. Your app Our relay Computer 1. Hello: one-time key, signed by your account key 2. Ready: the computer's one-time key Session keys HKDF-SHA-512 Session keys HKDF-SHA-512 3. Frames: XSalsa20-Poly1305, counted and padded The relay sees The handshake, who connects, when, and each frame's padded size No session keys: it cannot make them
Figure 2. The app and the computer each make session keys from one-time keys. The relay forwards the handshake and the sealed frames, and cannot make the keys.

What is sealed to whom#

Each kind of data, the key that seals it, and who can open it.

WhatSealed withWho can open itWhat our cloud holds
Chats, the queue, changes and voice between an app and a computerThe connection's session keysThe app and the computer at the two endsNothing: sealed frames pass through
Source sign-ins, API keys, MCP sign-insThe vault keyYour devices with the app. A computer gets the ones it uses from your app and keeps them in its own encrypted storeCiphertext, and each item's kind and provider
Project names, folders and git remotes; device namesThe vault keyYour devices with the appCiphertext, and a fingerprint of each git remote. A server's name is readable from when it joins until an app seals it
Settings only the apps readThe vault keyYour devices with the appCiphertext, and the few switches our cloud acts on, such as the language of its alerts
Notification settings, usage bars, source health, a computer's network addressesThe documents keyYour devices and your computersCiphertext, keyed network ids it can compare but not read, and the on/off and sound of the three alerts it sends itself
Your profile picture and project picturesThe pictures keyYour devices, and browsers you signed in onCiphertext
Push notifications from your computersThe phone's push keyThat phoneCiphertext, which it hands to Apple's or Google's push service
A reply you type on a notificationThe computer's key, sealed on the phoneThat computer. Anyone can seal to its key, our cloud included, so this keeps the reply private but does not prove it is the one you typedCiphertext while it waits
A call from GitHub or one of your automationsThe computer's key, sealed by our cloud when it arrivesThat computerThe message as it arrives, then ciphertext while it waits (up to 7 days)
A sign-in you keep up together with another Shuriken account (Pro)Sealed to their inbox key, signed by your account keyTheir devicesCiphertext
A new vault key after you remove a deviceEach remaining device's share keyThe devices you keptCiphertext

What our cloud sees, and what it never sees#

The short list. The security page names every item and how long it stays.

Never sees

  • Your password
  • Your vault key, or the account, inbox, documents and pictures keys made from it
  • Any connection's session keys
  • What is in your vault
  • Your chats, prompts, code and transcripts, apart from the exceptions under Known limits and what you send us yourself, such as a bug report

Sees

  • Your email address, first name and plan
  • Each device's kind, model, system and rough location
  • Which of your devices connect, when, for how long and how much they send, to the padding step
  • Public keys and signatures, including your account key in each handshake
  • The kind and provider of each vault item, not the sign-in or key
  • A fingerprint of each project's git remote
  • For a Codex sign-in you keep up together with another account (Pro), about once a day the identity statement OpenAI signed for it, never a credential

Key rotation and removing a device#

Removing a device with your password cuts it off from what is written after, with the exceptions under Known limits.

When you remove a device in Settings › Devices and confirm with your password, the device you are using rotates the vault key:

  1. It makes a new random vault key and encrypts every vault item again under it.
  2. It wraps the new key under your password key, so your next sign-in opens it. The earlier keys stay in a key ring sealed under the new one, so what was sealed before still opens on your devices.
  3. It seals the new key to the share key of each device you keep that one of your devices confirmed with your password. Nothing is sealed for the removed device, so it never receives the new key, and its old copy opens nothing written after. A device our cloud lists that none of your devices confirmed gets nothing either, and your apps ask whether to keep or remove it.
  4. The old account key signs the new account key with the next number in the chain.

Your computers read the rotation on their own: when they start, when our cloud says one happened, each time their events connection opens and every five minutes. They follow the chain from the key they pinned, and from then on they refuse a handshake that proves the old key and close every session that proved it, including those on your own network. An old key that signed two different new keys is refused.

With or without your password, the removed device's sign-in stops working and its connections through our cloud close at once. Removed without your password, for example from the device-limit screen, it keeps the vault key until one of your devices rotates it, and every signed-in app asks for your password until one does.

Removing a device with your password Figure 3. A new vault key is sealed to each device you keep. The removed device gets nothing, and your computers follow the old account key's signature to the new one. You remove a device Confirmed with your password New vault key Made at random; every item sealed again × Devices you keep Sealed to share key Removed device Gets nothing new Old account key signs new The next number in the chain Your computers follow it And refuse the old key from then on
Figure 3. A new vault key is sealed to each device you keep. The removed device gets nothing, and your computers follow the old account key's signature to the new one.

Known limits#

Where the encryption stops, or holds only with a condition. Each one is also on the security page.

  • In a browser, the code comes from our cloud

    The web app and the sign-in pages are served by our cloud each time you open them. A changed cloud could serve one browser a page that reads the password you type, your vault key and your chats. The desktop app checks the web app against a signed list of its files, but that check cannot see what a single browser gets, and the sign-in pages are not on that list. A browser also keeps your account key in its profile, as a key page script cannot export, which does not protect it on disk: whoever copies that profile, or opens that browser, can prove your account to your computers until you remove that browser with your password. A browser you said is not your computer keeps it in memory only. For anything sensitive, use the desktop app.

  • A server added by typing its code

    Your apps never trust its key, but a server that never joined before takes the first account key a client proves, and that comes through our cloud. A changed cloud could take the server over at that moment. The install command's join secret closes this, so use it.

  • A removed device working with our cloud

    After you remove a device, a computer that cannot reach our cloud keeps admitting the old account key on your own network until it can read the rotation. A computer offline since the removal first trusts whichever new key a client proves with a step the old key signed, and the removed device holds that old key too. The new vault key itself goes only to devices one of your devices confirmed with your password, so a device our cloud lists on its own gets nothing: your apps show it as a device you haven't confirmed, with Keep and Remove. An older app that does the rotation still seals it the old way, to every device our cloud lists that the old account key vouched for. And until a computer picks up the new documents key at its next vault sync, the usage readings and account health it writes stay under the old one.

  • What a removed device already had

    Rotation protects what is written after. AI sign-ins, API keys, GitHub and MCP sign-ins and project variables the device already opened keep working at their providers until you sign out there or replace them. Settings › Devices lists each one when you remove the device.

  • Your password is the last line

    Anyone with a copy of our database can try passwords offline: 600,000 PBKDF2 rounds a guess, 310,000 for a vault not moved yet, and 100,000 for an account that has not yet signed in on a current app. Use a long password you use nowhere else.

  • The first message of a connection

    An app may send one message before the handshake finishes: a hello with no password, credential or token in it. It is sealed with a key made without the computer's one-time key, so a recording of it opens with a computer key stolen later. Everything else waits for the session keys.

  • Sizes and timing

    Padding hides a frame's exact size, not how many frames go each way or when, so the rhythm of typing and of a streamed reply stays visible to the relay. The offer to pad travels in the open handshake. Until an app has seen a computer confirm what it offers, our relay can delete the offer and turn padding off for a connection to that computer. The computer's confirmation is sealed with the session keys, so the relay cannot write one, and the app remembers it: after that, the app refuses a handshake with that computer that leaves padding out. A new device, a reinstalled app or a browser whose storage was cleared has to see the confirmation again. The relay cannot turn encryption off. A connection with an app or computer from before padding, or with an app that does not offer it yet, is not padded.

  • Withholding and old copies

    Our cloud cannot read or change what it stores sealed, but it can hold an item back, and the item then reads as removed. A vault item older than one your device has opened is refused; an older sealed copy of a settings document or a project is not caught. A new device, or a browser whose storage was cleared, cannot tell an older vault item from the current one.

  • Older apps and computers

    A reply typed on a notification from an app, or to a computer, older than 5.1, and a call from GitHub or an automation for a computer older than 5.1, travel and wait readable. A computer older than 5.1 sends its network addresses readable. An app from 5.0.0 or earlier signs no time into its proof, so a recording of its connection played back after 30 days runs its first requests again, though the replayer reads none of the answers. A computer too old for session keys keeps one key for the whole connection, so nothing on it is forward secret.

  • Previews of dev servers

    A preview passes through our cloud without end-to-end encryption, because your browser reaches the dev server through our domain.

  • Agents on your computer

    An agent runs as you. Where the Shuriken app is signed in, it can read the vault key on that computer. Encryption keeps our cloud out, not a program running as you. Run agents and prompts you trust.

  • The first download

    The first copy of the desktop app comes from our cloud, and nothing checks it against our release key. Once installed, the app takes only updates signed with that key.

The code#

The files that do what this page describes, read from our repository each time this site is built.

Each file shows the last commit that changed it. Open one to read it. If the code does something this page says it doesn't, that is a vulnerability: tell us at office@shuriken.sh (also in our security.txt).

Built from commit 542b10d350, made on 2026-10-27. That is 26 commits after the Shuriken 5.1.3 release. 20 files, 6,422 lines.

Building blocks

crypto.tsApache-2.0 The primitives everything else uses: X25519, XSalsa20-Poly1305 with counter nonces, HKDF-SHA-512, HMAC-SHA-512 and the relay's session keys. packages/relay/src/crypto.ts · 436 lines · last changed 2026-10-08 in 9e4665940d
/// <reference lib="dom" />
/**
 * E2EE crypto primitives using NaCl (tweetnacl).
 *
 * - Key exchange: Curve25519 (nacl.box.before)
 * - Encryption: XSalsa20-Poly1305 secretbox (`SecretboxBackend`, tweetnacl unless one is installed)
 *
 * Bundle format (binary):
 *   [nonce (24 bytes)] [ciphertext...]
 *
 * The encrypted channel chooses the WebSocket representation. Crypto remains
 * byte-oriented so frame kind is never inferred from plaintext contents.
 */

import nacl from "tweetnacl";
import { fromByteArray, toByteArray } from "base64-js";

export interface KeyPair {
  publicKey: Uint8Array; // 32 bytes
  secretKey: Uint8Array; // 32 bytes
}

export type SharedKey = Uint8Array; // 32 bytes (box.before)

const NONCE_LENGTH = nacl.box.nonceLength; // 24
const MAC_LENGTH = nacl.secretbox.overheadLength; // 16
/** One nonce reused by the counter functions: they run to completion and keep no reference. */
const scratchNonce = new Uint8Array(NONCE_LENGTH);
const ZERO_X25519_SHARED_RESULT = new Uint8Array(nacl.box.sharedKeyLength);

/**
 * XSalsa20-Poly1305 secretbox in NaCl's layout, `[MAC (16)] [ciphertext]`: tweetnacl's
 * `secretbox`, libsodium's `crypto_secretbox_easy`. Every sealed relay frame and sealed document
 * goes through it (`box.after` is secretbox under the box.before key).
 *
 * Round 40 T1: tweetnacl runs it in JavaScript, which on the phone (Hermes) took 5–10 % of the JS
 * thread while a reply streamed. The phone installs libsodium over JSI here
 * (packages/app/src/polyfills/relay-secretbox.native.ts); daemons, the cloud, desktop and web keep
 * tweetnacl (WebCrypto has no XSalsa20).
 */
export interface SecretboxBackend {
  readonly name: string;
  seal(message: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array;
  /** The plaintext, or null when the box does not open under this nonce and key. */
  open(box: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array | null;
}

export const TWEETNACL_SECRETBOX: SecretboxBackend = {
  name: "tweetnacl",
  seal: (message, nonce, key) => nacl.secretbox(message, nonce, key),
  open: (box, nonce, key) => nacl.secretbox.open(box, nonce, key),
};

let secretbox: SecretboxBackend = TWEETNACL_SECRETBOX;

const SELF_TEST_KEY = Uint8Array.from({ length: 32 }, (_, i) => (i * 7 + 1) & 0xff);
const SELF_TEST_NONCE = Uint8Array.from({ length: NONCE_LENGTH }, (_, i) => (i * 13 + 5) & 0xff);
const SELF_TEST_MESSAGE = Uint8Array.from({ length: 131 }, (_, i) => (i * 31 + 11) & 0xff);
/**
 * What tweetnacl seals for the self-test message and for an empty one, written out: sealing them
 * in tweetnacl at every phone start cost 2–3 ms of Hermes before the first screen (round 40 T1).
 * `secretbox-backend.test.ts` checks they are still tweetnacl's bytes.
 */
export const SELF_TEST_SEALED = toByteArray(
  "6Yle1ERanjW+YOuWgURasr3rItcn5IHeHS0v7MeEoE5ilcnAOZu+xymBQRnfS+JLS98IeOQtpzHe/bHFZ4n7LkoncH3WGLey" +
    "nise+JbHVEnRTAhe2qj4RX2VDVEhjZLHInBGK3oLe1FM/SbvU1tjAw9U8Ep6jHXtrWsJ/1wVxaJM+Gxx64YnR1sQsuakguuGHmAl",
);
export const SELF_TEST_SEALED_EMPTY = toByteArray("0yw87bS1raxQRLLoZ4PNXg==");

function sameBytes(a: Uint8Array | null, b: Uint8Array): boolean {
  if (!a || a.byteLength !== b.byteLength) return false;
  for (let i = 0; i < a.byteLength; i += 1) if (a[i] !== b[i]) return false;
  return true;
}

/**
 * True when `backend` seals exactly what tweetnacl (the daemon's and the cloud's implementation)
 * seals, opens it, and refuses a box with one bit changed, an empty and a too-short box.
 */
export function secretboxMatchesTweetnacl(backend: SecretboxBackend): boolean {
  try {
    const expected = SELF_TEST_SEALED;
    const sealed = backend.seal(SELF_TEST_MESSAGE, SELF_TEST_NONCE, SELF_TEST_KEY);
    if (!sameBytes(sealed, expected)) return false;
    if (!sameBytes(backend.open(expected, SELF_TEST_NONCE, SELF_TEST_KEY), SELF_TEST_MESSAGE)) {
      return false;
    }
    const tampered = expected.slice();
    tampered[MAC_LENGTH + 3] ^= 1;
    if (backend.open(tampered, SELF_TEST_NONCE, SELF_TEST_KEY) !== null) return false;
    if (backend.open(new Uint8Array(0), SELF_TEST_NONCE, SELF_TEST_KEY) !== null) return false;
    if (backend.open(expected.subarray(0, 8), SELF_TEST_NONCE, SELF_TEST_KEY) !== null) {
      return false;
    }
    const empty = backend.seal(new Uint8Array(0), SELF_TEST_NONCE, SELF_TEST_KEY);
    return sameBytes(empty, SELF_TEST_SEALED_EMPTY);
  } catch {
    return false;
  }
}

/**
 * Seals and opens every secretbox with `backend` from now on, after it passes
 * `secretboxMatchesTweetnacl`; null (or a backend that fails the check) puts tweetnacl back.
 * Answers the backend in use.
 */
export function installSecretboxBackend(backend: SecretboxBackend | null): string {
  secretbox = backend && secretboxMatchesTweetnacl(backend) ? backend : TWEETNACL_SECRETBOX;
  return secretbox.name;
}

export function secretboxBackendName(): string {
  return secretbox.name;
}

/** NaCl secretbox through the installed backend (`secretbox.seal`, `secretbox.open`). */
export function secretboxSeal(message: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array {
  return secretbox.seal(message, nonce, key);
}

export function secretboxOpen(
  box: Uint8Array,
  nonce: Uint8Array,
  key: Uint8Array,
): Uint8Array | null {
  if (box.byteLength < MAC_LENGTH) return null;
  return secretbox.open(box, nonce, key);
}

let prngReady = false;

interface GlobalWithCrypto {
  crypto?: Crypto;
}

function getGlobalCrypto(): Crypto | undefined {
  const g = globalThis as GlobalWithCrypto;
  return g.crypto;
}

function ensurePrng(): void {
  if (prngReady) return;

  try {
    nacl.randomBytes(1);
    prngReady = true;
    return;
  } catch {
    // fallthrough
  }

  const cryptoObj = getGlobalCrypto();
  if (cryptoObj?.getRandomValues) {
    nacl.setPRNG((x, n) => {
      const buf = new Uint8Array(n);
      cryptoObj.getRandomValues(buf);
      x.set(buf, 0);
    });
    prngReady = true;
    return;
  }

  throw new Error("No secure PRNG available for tweetnacl (missing crypto.getRandomValues)");
}

function encodeBase64(bytes: Uint8Array): string {
  return fromByteArray(bytes);
}

function decodeBase64(base64: string): Uint8Array {
  return toByteArray(base64);
}

function decodePublicKeyBase64(base64: string): Uint8Array {
  if (
    typeof base64 !== "string" ||
    base64.length % 4 !== 0 ||
    !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(base64)
  ) {
    throw new Error("Invalid public key encoding");
  }

  const bytes = decodeBase64(base64);
  if (encodeBase64(bytes) !== base64) {
    throw new Error("Invalid public key encoding");
  }
  return bytes;
}

function toUint8(data: string | ArrayBuffer): Uint8Array {
  return typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
}

function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
  // A whole buffer is handed over as it is (the bundles below are fresh); a view is copied.
  if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
    return bytes.buffer as ArrayBuffer;
  }
  const out = new Uint8Array(bytes.byteLength);
  out.set(bytes);
  return out.buffer;
}

/** `[nonce (24)] [box]` in one allocation. */
function bundle(nonce: Uint8Array, box: Uint8Array): ArrayBuffer {
  const out = new Uint8Array(NONCE_LENGTH + box.byteLength);
  out.set(nonce, 0);
  out.set(box, NONCE_LENGTH);
  return out.buffer;
}

export function generateKeyPair(): KeyPair {
  ensurePrng();
  const { publicKey, secretKey } = nacl.box.keyPair();
  return { publicKey, secretKey };
}

export function exportPublicKey(publicKey: Uint8Array): string {
  if (!(publicKey instanceof Uint8Array) || publicKey.byteLength !== nacl.box.publicKeyLength) {
    throw new Error(`Invalid public key length (expected ${nacl.box.publicKeyLength})`);
  }
  return encodeBase64(publicKey);
}

export function importPublicKey(base64: string): Uint8Array {
  const bytes = decodePublicKeyBase64(base64);
  if (bytes.byteLength !== nacl.box.publicKeyLength) {
    throw new Error(`Invalid public key length (expected ${nacl.box.publicKeyLength})`);
  }
  return bytes;
}

export function exportSecretKey(secretKey: Uint8Array): string {
  if (!(secretKey instanceof Uint8Array) || secretKey.byteLength !== nacl.box.secretKeyLength) {
    throw new Error(`Invalid secret key length (expected ${nacl.box.secretKeyLength})`);
  }
  return encodeBase64(secretKey);
}

export function importSecretKey(base64: string): Uint8Array {
  const bytes = decodeBase64(base64);
  if (bytes.byteLength !== nacl.box.secretKeyLength) {
    throw new Error(`Invalid secret key length (expected ${nacl.box.secretKeyLength})`);
  }
  return bytes;
}

export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Array): SharedKey {
  if (ourSecretKey.byteLength !== nacl.box.secretKeyLength) {
    throw new Error(`Invalid secret key length (expected ${nacl.box.secretKeyLength})`);
  }
  if (peerPublicKey.byteLength !== nacl.box.publicKeyLength) {
    throw new Error(`Invalid peer public key length (expected ${nacl.box.publicKeyLength})`);
  }

  const rawSharedResult = nacl.scalarMult(ourSecretKey, peerPublicKey);
  const isAllZero = nacl.verify(rawSharedResult, ZERO_X25519_SHARED_RESULT);
  rawSharedResult.fill(0);
  if (isAllZero) {
    throw new Error("Invalid peer public key");
  }

  return nacl.box.before(peerPublicKey, ourSecretKey);
}

/**
 * Encrypts data and returns the binary bundle:
 *   [nonce (24)] [ciphertext...]
 */
export function encrypt(sharedKey: SharedKey, data: string | ArrayBuffer): ArrayBuffer {
  ensurePrng();
  const nonce = nacl.randomBytes(NONCE_LENGTH);
  return bundle(nonce, secretboxSeal(toUint8(data), nonce, sharedKey));
}

/** Big-endian 64-bit counter in the last 8 bytes of a 24-byte nonce (session keys, below). */
export function counterNonce(counter: number): Uint8Array {
  return writeCounterNonce(new Uint8Array(NONCE_LENGTH), counter);
}

function writeCounterNonce(nonce: Uint8Array, counter: number): Uint8Array {
  nonce.fill(0);
  let value = counter;
  for (let i = NONCE_LENGTH - 1; i >= NONCE_LENGTH - 8 && value > 0; i -= 1) {
    nonce[i] = value % 256;
    value = Math.floor(value / 256);
  }
  return nonce;
}

/**
 * Seals with a session key under the sender's next counter: `[nonce (24)] [ciphertext...]`, the
 * same bundle as `encrypt`, so the wire format is unchanged. Each direction has its own key, so
 * a counter never repeats under one key.
 */
export function encryptWithCounter(
  key: Uint8Array,
  counter: number,
  data: ArrayBuffer,
): ArrayBuffer {
  const nonce = writeCounterNonce(scratchNonce, counter);
  return bundle(nonce, secretboxSeal(new Uint8Array(data), nonce, key));
}

/**
 * Opens a session-key bundle only when its nonce is exactly the counter the receiver expects:
 * a replayed, dropped, reordered or reflected frame fails (SECURITY.md › Session keys).
 */
export function decryptWithCounter(
  key: Uint8Array,
  counter: number,
  data: ArrayBuffer,
): ArrayBuffer {
  return toArrayBuffer(openWithCounter(key, counter, data));
}

/** `decryptWithCounter` as bytes, without copying them into an ArrayBuffer of their own. */
export function openWithCounter(key: Uint8Array, counter: number, data: ArrayBuffer): Uint8Array {
  const bytes = new Uint8Array(data);
  if (bytes.byteLength < NONCE_LENGTH) throw new Error("Ciphertext bundle too short");
  const nonce = writeCounterNonce(scratchNonce, counter);
  for (let i = 0; i < NONCE_LENGTH; i += 1) {
    if (bytes[i] !== nonce[i]) throw new Error("Out-of-order or replayed frame");
  }
  const opened = secretboxOpen(bytes.subarray(NONCE_LENGTH), nonce, key);
  if (!opened) throw new Error("Decryption failed");
  return opened;
}

/** The raw X25519 result, refused when it is all zeros (a low-order peer key). */
function scalarMultChecked(secretKey: Uint8Array, publicKey: Uint8Array): Uint8Array {
  const shared = nacl.scalarMult(secretKey, publicKey);
  if (nacl.verify(shared, ZERO_X25519_SHARED_RESULT)) throw new Error("Invalid peer public key");
  return shared;
}

const SESSION_KEYS_INFO = "shuriken/relay/session-keys/v1";

/**
 * Session keys (Noise NK's shape): the client's ephemeral key meets the daemon's static key (es,
 * which only the real daemon can compute) and the daemon's ephemeral key (ee, which gives forward
 * secrecy: once both ephemeral keys are gone, a stolen static key opens nothing recorded). One key
 * per direction, so a frame reflected back to its sender never opens.
 */
export function deriveSessionKeys(input: {
  role: "client" | "daemon";
  ourEphemeral: KeyPair;
  /** The daemon's static key pair (daemon side) or its static public key (client side). */
  daemonStatic: KeyPair | Uint8Array;
  peerEphemeralPublicKey: Uint8Array;
  clientEphemeralPublicKey: Uint8Array;
  daemonEphemeralPublicKey: Uint8Array;
}): { sendKey: Uint8Array; receiveKey: Uint8Array } {
  const daemonStaticPublic =
    input.daemonStatic instanceof Uint8Array ? input.daemonStatic : input.daemonStatic.publicKey;
  const es =
    input.role === "client"
      ? scalarMultChecked(input.ourEphemeral.secretKey, daemonStaticPublic)
      : scalarMultChecked((input.daemonStatic as KeyPair).secretKey, input.peerEphemeralPublicKey);
  const ee = scalarMultChecked(input.ourEphemeral.secretKey, input.peerEphemeralPublicKey);
  const salt = concatBytes(
    input.clientEphemeralPublicKey,
    daemonStaticPublic,
    input.daemonEphemeralPublicKey,
  );
  const okm = hkdfSha512(concatBytes(es, ee), salt, utf8(SESSION_KEYS_INFO), 64);
  es.fill(0);
  ee.fill(0);
  const clientToDaemon = okm.slice(0, 32);
  const daemonToClient = okm.slice(32, 64);
  okm.fill(0);
  return input.role === "client"
    ? { sendKey: clientToDaemon, receiveKey: daemonToClient }
    : { sendKey: daemonToClient, receiveKey: clientToDaemon };
}

export function utf8(text: string): Uint8Array {
  return new TextEncoder().encode(text);
}

export function concatBytes(...parts: Uint8Array[]): Uint8Array {
  const out = new Uint8Array(parts.reduce((sum, part) => sum + part.byteLength, 0));
  let offset = 0;
  for (const part of parts) {
    out.set(part, offset);
    offset += part.byteLength;
  }
  return out;
}

const SHA512_BLOCK = 128;

/** HMAC-SHA-512 (RFC 2104) over tweetnacl's SHA-512, so every platform runs the same code. */
export function hmacSha512(key: Uint8Array, message: Uint8Array): Uint8Array {
  const block = new Uint8Array(SHA512_BLOCK);
  block.set(key.byteLength > SHA512_BLOCK ? nacl.hash(key) : key);
  const inner = new Uint8Array(SHA512_BLOCK);
  const outer = new Uint8Array(SHA512_BLOCK);
  for (let i = 0; i < SHA512_BLOCK; i += 1) {
    inner[i] = block[i] ^ 0x36;
    outer[i] = block[i] ^ 0x5c;
  }
  return nacl.hash(concatBytes(outer, nacl.hash(concatBytes(inner, message))));
}

/** HKDF-SHA-512 (RFC 5869) for at most one block (64 bytes), which is all Shuriken asks for. */
export function hkdfSha512(
  ikm: Uint8Array,
  salt: Uint8Array,
  info: Uint8Array,
  length: number,
): Uint8Array {
  if (length < 1 || length > 64) throw new Error("hkdfSha512 gives 1 to 64 bytes");
  const prk = hmacSha512(salt.byteLength > 0 ? salt : new Uint8Array(64), ikm);
  const okm = hmacSha512(prk, concatBytes(info, new Uint8Array([1])));
  prk.fill(0);
  return okm.slice(0, length);
}

export function decrypt(sharedKey: SharedKey, data: ArrayBuffer): ArrayBuffer {
  const bytes = new Uint8Array(data);
  if (bytes.byteLength < NONCE_LENGTH) {
    throw new Error("Ciphertext bundle too short");
  }

  const opened = secretboxOpen(
    bytes.subarray(NONCE_LENGTH),
    bytes.subarray(0, NONCE_LENGTH),
    sharedKey,
  );
  if (!opened) {
    throw new Error("Decryption failed");
  }

  return toArrayBuffer(opened);
}

Your password and the vault

vault-crypto.ts The password key (PBKDF2-SHA256), wrapping the vault key with it, and sealing each vault item with AES-256-GCM. packages/app/src/account/vault-crypto.ts · 329 lines · last changed 2026-10-23 in e34f3de07d
import { deriveAuthKey } from "@shuriken/client";
import { base64ToBytes, bytesToBase64, utf8Decode, utf8Encode } from "./base64";
import type { LegacyKeyParams, VaultInfo } from "./cloud-api";
import type { VaultCryptoBackend } from "./vault-crypto-backend-types";

// Client half of the vault key scheme in docs/cloud.md:
//   KEK = PBKDF2-SHA256(password, vault.salt, vault.iterations, 256 bits)
//   wrappedKey = base64(iv[12] || AES-GCM(KEK, iv, vaultKey[32]) [48])   — 60 bytes
//   item: ciphertext = base64(AES-GCM(vaultKey, iv, JSON)), iv = base64(12 random bytes)

/**
 * Round 36 S3: the KEK's rounds for every new wrap (OWASP's PBKDF2-SHA256 figure, Bitwarden's
 * default). An account still on 310,000 moves the next time its app holds the password.
 */
export const VAULT_KDF_ITERATIONS_NEXT = 600_000;
/**
 * Round 36 S3: the fewest rounds a device derives an auth key with — every account has had at
 * least this many since the vault began. The cloud names the rounds, so a hostile one could ask
 * for a handful and brute-force the password back from the auth key it is sent.
 */
export const VAULT_KDF_ITERATIONS_FLOOR = 310_000;

const IV_BYTES = 12;
const VAULT_KEY_BYTES = 32;
const WRAPPED_KEY_BYTES = IV_BYTES + VAULT_KEY_BYTES + 16;

export class WrongPasswordError extends Error {
  constructor() {
    super("That password does not open this account's vault");
    this.name = "WrongPasswordError";
  }
}

export class VaultItemDecryptError extends Error {
  constructor(id: string) {
    super(`Vault item ${id} could not be decrypted with this account's vault key`);
    this.name = "VaultItemDecryptError";
  }
}

async function deriveKek(
  backend: VaultCryptoBackend,
  password: string,
  vault: VaultInfo,
): Promise<Uint8Array> {
  return backend.deriveKek(password, base64ToBytes(vault.salt), vault.iterations);
}

export async function unwrapVaultKeyWithKek(
  backend: VaultCryptoBackend,
  kek: Uint8Array,
  wrappedKey: string,
): Promise<Uint8Array> {
  const wrapped = base64ToBytes(wrappedKey);
  if (wrapped.length !== WRAPPED_KEY_BYTES) throw new WrongPasswordError();
  try {
    return await backend.decrypt(kek, wrapped.subarray(0, IV_BYTES), wrapped.subarray(IV_BYTES));
  } catch {
    throw new WrongPasswordError();
  }
}

/** The account's vault key, or null while the account has none yet (before the first change). */
export async function unwrapVaultKey(
  backend: VaultCryptoBackend,
  password: string,
  vault: VaultInfo,
): Promise<Uint8Array | null> {
  if (!vault.wrappedKey) return null;
  return unwrapVaultKeyWithKek(
    backend,
    await deriveKek(backend, password, vault),
    vault.wrappedKey,
  );
}

export async function wrapVaultKey(
  backend: VaultCryptoBackend,
  password: string,
  vault: VaultInfo,
  vaultKey: Uint8Array,
): Promise<string> {
  const kek = await deriveKek(backend, password, vault);
  const iv = backend.randomBytes(IV_BYTES);
  const sealed = await backend.encrypt(kek, iv, vaultKey);
  const out = new Uint8Array(IV_BYTES + sealed.length);
  out.set(iv);
  out.set(sealed, IV_BYTES);
  return bytesToBase64(out);
}

/**
 * The `wrappedVaultKey` for a password change: the existing vault key re-wrapped under the new
 * password (items are not re-encrypted), or a fresh key at the forced first change when the
 * account has none. Returns the vault key too, so the device can keep it.
 */
export async function rewrapVaultKey(
  backend: VaultCryptoBackend,
  vault: VaultInfo,
  currentPassword: string,
  newPassword: string,
): Promise<{ wrappedVaultKey: string; vaultKey: Uint8Array }> {
  const vaultKey =
    (await unwrapVaultKey(backend, currentPassword, vault)) ?? backend.randomBytes(VAULT_KEY_BYTES);
  return { wrappedVaultKey: await wrapVaultKey(backend, newPassword, vault, vaultKey), vaultKey };
}

/** What a password gives under a vault's salt and rounds: the KEK, and the auth key from it. */
export interface PasswordSecrets {
  kek: Uint8Array;
  /** Sent to the cloud instead of the password (round 36 S3). */
  authKey: string;
  salt: string;
  iterations: number;
}

export async function passwordSecrets(
  backend: VaultCryptoBackend,
  password: string,
  params: { salt: string; iterations: number },
): Promise<PasswordSecrets> {
  const kek = await backend.deriveKek(password, base64ToBytes(params.salt), params.iterations);
  return { kek, authKey: deriveAuthKey(kek), salt: params.salt, iterations: params.iterations };
}

/** Round 36 S3: the fewest rounds a password hash has ever had (the cloud's PBKDF2 ceiling). */
export const LEGACY_KEY_ITERATIONS_FLOOR = 100_000;
/**
 * Round 36 S3 (adversarial verify): the most rounds a password hash can have — the Worker's
 * WebCrypto refuses PBKDF2 above 100,000, so every hash it (or seed-owner) made has exactly that.
 * The legacy key is the same PBKDF2 as the KEK, whose rounds are never fewer than 310,000: a cloud
 * naming the vault's own salt and rounds as the hash's would otherwise be sent the KEK itself.
 */
export const LEGACY_KEY_ITERATIONS_CEILING = 100_000;
const LEGACY_SALT_BYTES = 16;

export class LegacyParamsError extends Error {
  constructor() {
    super("The cloud named password-hash parameters weaker than any account has had");
    this.name = "LegacyParamsError";
  }
}

/**
 * Round 36 S3: what an account with no auth key yet signs in with instead of the password — the
 * PBKDF2-SHA256 output (256 bits) of the password under its stored hash's salt and rounds, the
 * value the cloud already holds. A cloud naming fewer rounds or a short salt is refused: the
 * output would brute-force back to the password. More rounds than any hash has are refused too:
 * at the KEK's rounds and salt the output would be the KEK.
 */
export async function legacyKey(
  backend: VaultCryptoBackend,
  password: string,
  params: LegacyKeyParams | undefined,
  floor: number = LEGACY_KEY_ITERATIONS_FLOOR,
): Promise<string> {
  if (!params || !Number.isInteger(params.iterations)) throw new LegacyParamsError();
  if (params.iterations < floor) throw new LegacyParamsError();
  if (params.iterations > LEGACY_KEY_ITERATIONS_CEILING) throw new LegacyParamsError();
  let salt: Uint8Array;
  try {
    salt = base64ToBytes(params.salt);
  } catch {
    throw new LegacyParamsError();
  }
  if (salt.length !== LEGACY_SALT_BYTES) throw new LegacyParamsError();
  return bytesToBase64(await backend.deriveKek(password, salt, params.iterations));
}

/**
 * Round 60 #34: what the cloud hands out as a one-time password (admission, the owner's reset,
 * the waitlist page's claim; cloud lib/crypto.ts `newOneTimePassword`): 16 characters from 55
 * with no look-alikes, about 92 random bits.
 */
const ONE_TIME_PASSWORD = /^[abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789]{16}$/;

/**
 * Round 60 #34: whether `password` has a one-time password's shape. A device that moved an account
 * to an auth key refuses to send any legacy key for it again (a cloud asking is fishing for the
 * password), except one of these: after the owner's reset the cloud asks for exactly that, and a
 * legacy key of 92 random bits brute-forces back to nothing.
 */
export function isOneTimePasswordShape(password: string): boolean {
  return ONE_TIME_PASSWORD.test(password);
}

export async function wrapVaultKeyWithKek(
  backend: VaultCryptoBackend,
  kek: Uint8Array,
  vaultKey: Uint8Array,
): Promise<string> {
  const iv = backend.randomBytes(IV_BYTES);
  const sealed = await backend.encrypt(kek, iv, vaultKey);
  const out = new Uint8Array(IV_BYTES + sealed.length);
  out.set(iv);
  out.set(sealed, IV_BYTES);
  return bytesToBase64(out);
}

/**
 * Round 36 S3: a password change (or an account's first auth key) as the cloud takes it — the
 * current secret (the auth key, or the legacy key while the account has none), the new auth key and
 * the vault key wrapped under the new KEK at the new rounds. The new password never leaves here.
 *
 * Round 59 hotfix: `currentPassword` null is the forced first change after the one-time password
 * was forgotten here (an app restart, a restored session): no current secret is sent — the
 * pending session is the proof — and the vault starts with a new key (an admitted or reset
 * account has none to open).
 */
/** The current secret a change sends: the legacy key, else the auth key, else none (forced). */
function currentProof(
  currentLegacyKey: string | null,
  currentAuthKey: string | null,
): { currentLegacyKey: string } | { currentAuthKey: string } | null {
  if (currentLegacyKey !== null) return { currentLegacyKey };
  return currentAuthKey !== null ? { currentAuthKey } : null;
}

/**
 * Audit pass 2 M16: the current password's proof alone (as a change sends it), for the changes
 * that ask for the password without changing it: turning two-factor sign-in on or off.
 */
export async function passwordProof(
  backend: VaultCryptoBackend,
  vault: VaultInfo,
  password: string,
  iterations: number = VAULT_KDF_ITERATIONS_NEXT,
): Promise<{ currentLegacyKey: string } | { currentAuthKey: string }> {
  if (vault.scheme === "password") {
    return {
      currentLegacyKey: await legacyKey(
        backend,
        password,
        vault.legacy,
        Math.min(LEGACY_KEY_ITERATIONS_FLOOR, iterations),
      ),
    };
  }
  return { currentAuthKey: (await passwordSecrets(backend, password, vault)).authKey };
}

export async function passwordChange(
  backend: VaultCryptoBackend,
  vault: VaultInfo,
  currentPassword: string | null,
  newPassword: string,
  iterations: number = VAULT_KDF_ITERATIONS_NEXT,
): Promise<{
  request: {
    currentLegacyKey?: string;
    currentAuthKey?: string;
    newAuthKey: string;
    wrappedVaultKey: string;
    iterations: number;
  };
  vaultKey: Uint8Array;
  /** The KEKs of the current password (null without one) and the new one, for the caller only. */
  keks: { current: Uint8Array | null; next: Uint8Array };
}> {
  // A weak legacy hash is refused before anything else is derived. Tests derive with a handful of
  // rounds; the floor never drops below theirs.
  const currentLegacyKey =
    currentPassword !== null && vault.scheme === "password"
      ? await legacyKey(
          backend,
          currentPassword,
          vault.legacy,
          Math.min(LEGACY_KEY_ITERATIONS_FLOOR, iterations),
        )
      : null;
  const current =
    currentPassword === null ? null : await passwordSecrets(backend, currentPassword, vault);
  const vaultKey =
    vault.wrappedKey && current
      ? await unwrapVaultKeyWithKek(backend, current.kek, vault.wrappedKey)
      : backend.randomBytes(VAULT_KEY_BYTES);
  const next = await passwordSecrets(backend, newPassword, { salt: vault.salt, iterations });
  return {
    request: {
      ...currentProof(currentLegacyKey, current?.authKey ?? null),
      newAuthKey: next.authKey,
      wrappedVaultKey: await wrapVaultKeyWithKek(backend, next.kek, vaultKey),
      iterations,
    },
    vaultKey,
    keks: { current: current?.kek ?? null, next: next.kek },
  };
}

export async function encryptVaultValue(
  backend: VaultCryptoBackend,
  vaultKey: Uint8Array,
  value: unknown,
): Promise<{ ciphertext: string; iv: string }> {
  const iv = backend.randomBytes(IV_BYTES);
  const sealed = await backend.encrypt(vaultKey, iv, utf8Encode(JSON.stringify(value)));
  return { ciphertext: bytesToBase64(sealed), iv: bytesToBase64(iv) };
}

export async function decryptVaultValue(
  backend: VaultCryptoBackend,
  vaultKey: Uint8Array,
  item: { id: string; ciphertext: string; iv: string },
): Promise<unknown> {
  let opened: Uint8Array;
  try {
    opened = await backend.decrypt(
      vaultKey,
      base64ToBytes(item.iv),
      base64ToBytes(item.ciphertext),
    );
  } catch {
    throw new VaultItemDecryptError(item.id);
  }
  return JSON.parse(utf8Decode(opened));
}

export function encodeVaultKey(vaultKey: Uint8Array): string {
  return bytesToBase64(vaultKey);
}

export function decodeVaultKey(stored: string): Uint8Array | null {
  try {
    const bytes = base64ToBytes(stored);
    return bytes.length === VAULT_KEY_BYTES ? bytes : null;
  } catch {
    return null;
  }
}
vault-crypto-webcrypto.ts The same operations in a browser and the desktop app, through WebCrypto. packages/app/src/account/vault-crypto-webcrypto.ts · 46 lines · last changed 2026-09-28 in cde798fde1
import { utf8Encode } from "./base64";
import type { VaultCryptoBackend } from "./vault-crypto-backend-types";

// WebCrypto takes BufferSource; copying pins the ArrayBuffer type TypeScript wants.
const buffer = (bytes: Uint8Array): Uint8Array<ArrayBuffer> => new Uint8Array(bytes);

async function aesKey(raw: Uint8Array, usage: KeyUsage): Promise<CryptoKey> {
  return globalThis.crypto.subtle.importKey("raw", buffer(raw), "AES-GCM", false, [usage]);
}

/** Web and Electron: WebCrypto, the same code path the cloud site uses. */
export const webCryptoVaultBackend: VaultCryptoBackend = {
  async deriveKek(password, salt, iterations) {
    const subtle = globalThis.crypto.subtle;
    const material = await subtle.importKey("raw", buffer(utf8Encode(password)), "PBKDF2", false, [
      "deriveBits",
    ]);
    const bits = await subtle.deriveBits(
      { name: "PBKDF2", hash: "SHA-256", salt: buffer(salt), iterations },
      material,
      256,
    );
    return new Uint8Array(bits);
  },
  async encrypt(key, iv, plaintext) {
    const sealed = await globalThis.crypto.subtle.encrypt(
      { name: "AES-GCM", iv: buffer(iv) },
      await aesKey(key, "encrypt"),
      buffer(plaintext),
    );
    return new Uint8Array(sealed);
  },
  async decrypt(key, iv, ciphertext) {
    const opened = await globalThis.crypto.subtle.decrypt(
      { name: "AES-GCM", iv: buffer(iv) },
      await aesKey(key, "decrypt"),
      buffer(ciphertext),
    );
    return new Uint8Array(opened);
  },
  randomBytes(length) {
    const bytes = new Uint8Array(length);
    globalThis.crypto.getRandomValues(bytes);
    return bytes;
  },
};
vault-crypto-noble.ts The same operations on a phone, where there is no WebCrypto: the noble JavaScript libraries, and the platform's own PBKDF2 on Android. packages/app/src/account/vault-crypto-noble.ts · 101 lines · last changed 2026-10-08 in b3d259d54b
import { gcm } from "@noble/ciphers/aes.js";
import { pbkdf2Async } from "@noble/hashes/pbkdf2.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { base64ToBytes, bytesToBase64, utf8Encode } from "./base64";
import type { VaultCryptoBackend } from "./vault-crypto-backend-types";

function randomBytes(length: number): Uint8Array {
  const bytes = new Uint8Array(length);
  globalThis.crypto.getRandomValues(bytes);
  return bytes;
}

/** React Native: pure-JS primitives. `pbkdf2Async` yields between rounds so the UI keeps moving. */
export const nobleVaultCryptoBackend: VaultCryptoBackend = {
  deriveKek: (password, salt, iterations) =>
    pbkdf2Async(sha256, utf8Encode(password), salt, { c: iterations, dkLen: 32 }),
  encrypt: async (key, iv, plaintext) => gcm(key, iv).encrypt(plaintext),
  decrypt: async (key, iv, ciphertext) => gcm(key, iv).decrypt(ciphertext),
  randomBytes,
};

/** modules/shuriken-pbkdf2 (Android): PBKDF2-HMAC-SHA256 over base64 bytes, base64 out. */
export interface NativePbkdf2 {
  pbkdf2Sha256(password: string, salt: string, iterations: number, length: number): Promise<string>;
}

/**
 * The noble backend with the platform's PBKDF2 when the build has it. Hermes has no JIT: the
 * pure-JS KDF took about four minutes for the vault's 310,000 iterations on the Android emulator,
 * and sign-in, the password change and the vault unlock each waited that long. The password goes
 * over as its UTF-8 bytes, so the native side hashes exactly what WebCrypto and noble hash.
 */
export function withNativeKdf(
  backend: VaultCryptoBackend,
  native: NativePbkdf2 | null,
): VaultCryptoBackend {
  if (!native) return backend;
  return {
    ...backend,
    deriveKek: async (password, salt, iterations) =>
      base64ToBytes(
        await native.pbkdf2Sha256(
          bytesToBase64(utf8Encode(password)),
          bytesToBase64(salt),
          iterations,
          32,
        ),
      ),
  };
}

/** The bytes as a string of char codes 0–255: a Map key with no encoding work per byte. */
function latin1(bytes: Uint8Array): string {
  let out = "";
  for (let i = 0; i < bytes.length; i += 8192) {
    out += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + 8192)));
  }
  return out;
}

export interface DecryptMemoLimits {
  entries: number;
  /** Larger boxes (pictures) are opened every time. */
  maxCiphertextBytes: number;
}

const DECRYPT_MEMO_LIMITS: DecryptMemoLimits = { entries: 512, maxCiphertextBytes: 16 * 1024 };

/**
 * Round 40 T1: AES-GCM in JavaScript (noble) costs ~2–8 ms a box in Hermes, its GHASH tables
 * built per call, and the phone opened the same small boxes on every project switch (the
 * project's sealed record, its env for the hazard light, sealed settings, device names). A box
 * that opened once answers its plaintext again: the same key, IV and ciphertext can only hold
 * that plaintext, and a box that fails to open is never remembered. The memo is keyed by the key's
 * bytes, so a locked vault (no key) can read nothing from it.
 */
export function withDecryptMemo(
  backend: VaultCryptoBackend,
  limits: DecryptMemoLimits = DECRYPT_MEMO_LIMITS,
): VaultCryptoBackend {
  const opened = new Map<string, Uint8Array>();
  return {
    ...backend,
    decrypt: async (key, iv, ciphertext) => {
      if (ciphertext.length > limits.maxCiphertextBytes)
        return backend.decrypt(key, iv, ciphertext);
      const id = `${latin1(key)}\u0000${latin1(iv)}\u0000${latin1(ciphertext)}`;
      const known = opened.get(id);
      if (known) {
        // Most recently used last, so the oldest goes first.
        opened.delete(id);
        opened.set(id, known);
        return known.slice();
      }
      const plaintext = await backend.decrypt(key, iv, ciphertext);
      opened.set(id, plaintext.slice());
      if (opened.size > limits.entries) opened.delete(opened.keys().next().value as string);
      return plaintext;
    },
  };
}
vault-client.ts Reading and writing vault items. Each sealed value names its id, its kind and a revision, so the cloud cannot move it to another slot. packages/app/src/account/vault-client.ts · 332 lines · last changed 2026-10-23 in e34f3de07d
import { CloudApiError, type CloudApi, type VaultItem } from "./cloud-api";
import { decryptVaultValue, encryptVaultValue, VaultItemDecryptError } from "./vault-crypto";
import type { VaultCryptoBackend } from "./vault-crypto-backend-types";
import { memoryVaultRevisions, type VaultRevisions } from "./vault-revisions";

// The account vault (docs/cloud.md › Vault): ciphertext in the cloud, opened on the device with
// the vault key. The daemon-side accounts module syncs through this client (AccountVaultSync).

export interface VaultEntry<T = unknown> {
  id: string;
  kind: string;
  label: string;
  version: number;
  value: T;
  updatedAt: string;
}

/** An item that is there but this device cannot open (sealed under another key, or damaged). */
export interface UnopenedVaultEntry {
  id: string;
  kind: string;
  label: string;
  version: number;
  updatedAt: string;
  unopened: true;
}

/** A stale or duplicate write. `current` is the stored item (null when it was deleted). */
export class VaultConflictError extends Error {
  readonly current: VaultEntry | null;

  constructor(id: string, current: VaultEntry | null) {
    super(`Vault item ${id} changed on another device`);
    this.name = "VaultConflictError";
    this.current = current;
  }
}

export class VaultLockedError extends Error {
  constructor() {
    super("The vault is locked on this device; unlock it with your password");
    this.name = "VaultLockedError";
  }
}

/**
 * An item's sealed value that this device will not open: older than one it already opened or
 * wrote, or with no revision once every item of the account was sealed with one (audit VAULT-11).
 * A VaultItemDecryptError, so every reader treats it as a value it cannot open (sign in again),
 * never as a removed one.
 */
export class VaultItemRollbackError extends VaultItemDecryptError {
  constructor(id: string) {
    super(id);
    this.message = `Vault item ${id} is older than one this device already has`;
    this.name = "VaultItemRollbackError";
  }
}

/**
 * Security audit (v5.1.0): what an item's ciphertext opens to — its value with the item's id and
 * kind, so whoever writes /api/vault/items cannot move one item's sealed value into another slot
 * or kind. Audit VAULT-11: and a revision, higher at every write (above the highest the writer
 * has seen and no lower than its clock, so an item removed and added again from a device that
 * never saw it still goes up). Each device keeps the highest revision it opened or wrote per item
 * (vault-revisions.ts) and refuses a lower one: the cloud cannot serve back a value the person
 * replaced. A value with no revision (the value alone, or `ITEM_ENVELOPE: 1`, sealed by an app
 * from before) opens until this device's keeper pass (bindLegacyItems) has sealed every item
 * again with one; from then on it is refused (docs/security.md › Moving to sealed data).
 */
const ITEM_ENVELOPE = "shuriken.vault-item";
const ENVELOPE_REVISED = 2;

/** An item's value as it is sealed (also what a rotation seals for an item it rewrites). */
export function bindVaultItem(
  id: string,
  kind: string,
  revision: number,
  value: unknown,
): Record<string, unknown> {
  return { [ITEM_ENVELOPE]: ENVELOPE_REVISED, id, kind, revision, value };
}

/** An opened item's value, and its revision (null: sealed before revisions). */
interface Unbound {
  value: unknown;
  revision: number | null;
}

/** The value of an opened item, checked against the slot it came from. */
function unbindItem(item: VaultItem, opened: unknown): Unbound {
  if (typeof opened !== "object" || opened === null || !(ITEM_ENVELOPE in opened)) {
    return { value: opened, revision: null };
  }
  const envelope = opened as Record<string, unknown> & {
    id?: unknown;
    kind?: unknown;
    revision?: unknown;
    value?: unknown;
  };
  if (envelope.id !== item.id || envelope.kind !== item.kind) {
    throw new VaultItemDecryptError(item.id);
  }
  if (envelope[ITEM_ENVELOPE] === 1) return { value: envelope.value, revision: null };
  const revision = envelope.revision;
  if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 1) {
    throw new VaultItemDecryptError(item.id);
  }
  return { value: envelope.value, revision };
}

export interface VaultClientDeps {
  api: () => CloudApi;
  crypto: VaultCryptoBackend;
  /** The unwrapped vault key held on this device, or null while locked. */
  vaultKey: () => Uint8Array | null;
  /**
   * Asks the user to unlock the vault (the web app keeps the key only for the tab's session).
   * Resolves true once it is unlocked. Without it a locked vault throws VaultLockedError.
   */
  requestUnlock?: () => Promise<boolean>;
  /**
   * Audit VAULT-11: the highest revision this device has seen per item (kept across restarts by
   * the account service). Without it, a record for this client's life only.
   */
  revisions?: VaultRevisions;
  /** The clock a revision is never lower than (ms). */
  now?: () => number;
}

export class VaultClient {
  private readonly revisions: VaultRevisions;

  constructor(private readonly deps: VaultClientDeps) {
    this.revisions = deps.revisions ?? memoryVaultRevisions();
  }

  isUnlocked(): boolean {
    return this.deps.vaultKey() !== null;
  }

  /** True once the vault is open, prompting for the password when this device can. */
  async ensureUnlocked(): Promise<boolean> {
    if (this.isUnlocked()) return true;
    return (await this.deps.requestUnlock?.()) === true && this.isUnlocked();
  }

  async list<T = unknown>(filter?: { kind?: string }): Promise<VaultEntry<T>[]> {
    const items = await this.deps.api().listVaultItems();
    const wanted = filter?.kind ? items.filter((item) => item.kind === filter.kind) : items;
    if (wanted.length === 0) return [];
    const key = await this.key();
    return Promise.all(wanted.map((item) => this.open<T>(item, key)));
  }

  /**
   * Round 42 W2 verify: list() fails whole on one item it cannot open, which stopped every
   * source's sync. A sync that must tell "gone" from "there" lists here: an item it cannot open is
   * listed as such, never left out (left out, it reads as removed elsewhere).
   */
  async listSettled<T = unknown>(filter?: {
    kind?: string;
  }): Promise<Array<VaultEntry<T> | UnopenedVaultEntry>> {
    const items = await this.deps.api().listVaultItems();
    const wanted = filter?.kind ? items.filter((item) => item.kind === filter.kind) : items;
    if (wanted.length === 0) return [];
    const key = await this.key();
    return Promise.all(
      wanted.map(async (item): Promise<VaultEntry<T> | UnopenedVaultEntry> => {
        try {
          return await this.open<T>(item, key);
        } catch (error) {
          if (!(error instanceof VaultItemDecryptError)) throw error;
          const { id, kind, label, version, updatedAt } = item;
          return { id, kind, label, version, updatedAt, unopened: true };
        }
      }),
    );
  }

  async get<T = unknown>(id: string): Promise<VaultEntry<T> | null> {
    const item = await this.deps.api().getVaultItem(id);
    return item ? this.open<T>(item, await this.key()) : null;
  }

  /**
   * Writes `value` under `id`. `version` is the one read (0 creates). A concurrent write answers
   * VaultConflictError carrying the stored item: merge and retry with its version, or use update().
   */
  async put<T>(
    id: string,
    input: { kind: string; label: string; value: T; version: number },
  ): Promise<VaultEntry<T>> {
    const key = await this.key();
    const revision = Math.max(
      (await this.revisions.seen(id)) + 1,
      Math.floor((this.deps.now ?? Date.now)()),
    );
    const sealed = await encryptVaultValue(
      this.deps.crypto,
      key,
      bindVaultItem(id, input.kind, revision, input.value),
    );
    try {
      const stored = await this.deps.api().putVaultItem(id, {
        kind: input.kind,
        label: input.label,
        version: input.version,
        ...sealed,
      });
      await this.revisions.raise(id, revision);
      return this.toEntry(stored, input.value);
    } catch (error) {
      throw await this.conflictOr(id, error);
    }
  }

  async delete(id: string, version: number): Promise<void> {
    try {
      await this.deps.api().deleteVaultItem(id, version);
    } catch (error) {
      throw await this.conflictOr(id, error);
    }
  }

  /**
   * Read-modify-write that retries on conflicts: `mutate` gets the current value (null when
   * absent) and returns the next one. Used for token refreshes written back under a Hub lease.
   */
  async update<T>(
    id: string,
    input: { kind: string; label: string },
    mutate: (current: VaultEntry<T> | null) => T,
    options: { attempts?: number } = {},
  ): Promise<VaultEntry<T>> {
    let current = await this.get<T>(id);
    const attempts = options.attempts ?? 3;
    for (let attempt = 1; ; attempt += 1) {
      try {
        return await this.put(id, {
          ...input,
          value: mutate(current),
          version: current?.version ?? 0,
        });
      } catch (error) {
        if (!(error instanceof VaultConflictError) || attempt >= attempts) throw error;
        current = error.current as VaultEntry<T> | null;
      }
    }
  }

  private async key(): Promise<Uint8Array> {
    if (!(await this.ensureUnlocked())) throw new VaultLockedError();
    const key = this.deps.vaultKey();
    if (!key) throw new VaultLockedError();
    return key;
  }

  /**
   * Audit VAULT-11: the keeper pass. Every item this device opens that carries no revision is
   * sealed again in the bound form, with one (a write at its version: a device that wrote it
   * meanwhile wins, and the pass runs again). Once a pass found every item it can open bound,
   * this device refuses a value with no revision. An empty vault closes nothing: an app from
   * before may still add the first item (there is nothing to serve back yet). Idempotent; true
   * when no item is left to bind.
   */
  async bindLegacyItems(): Promise<boolean> {
    if (await this.revisions.legacyClosed()) return true;
    const items = await this.deps.api().listVaultItems();
    let done = true;
    if (items.length > 0) {
      const key = await this.key();
      for (const item of items) {
        let opened: Unbound;
        try {
          opened = unbindItem(item, await decryptVaultValue(this.deps.crypto, key, item));
        } catch (error) {
          // Sealed with a key this device never had, or damaged: nothing to bind here.
          if (error instanceof VaultItemDecryptError) continue;
          throw error;
        }
        if (opened.revision !== null) continue;
        // One this device saw with a revision is a value served back, not one to keep.
        if ((await this.revisions.seen(item.id)) > 0) continue;
        try {
          await this.put(item.id, {
            kind: item.kind,
            label: item.label,
            value: opened.value,
            version: item.version,
          });
        } catch (error) {
          if (!(error instanceof VaultConflictError)) throw error;
          done = false;
        }
      }
    }
    if (done && items.length > 0) await this.revisions.closeLegacy();
    return done;
  }

  private async open<T>(item: VaultItem, key: Uint8Array): Promise<VaultEntry<T>> {
    const opened = unbindItem(item, await decryptVaultValue(this.deps.crypto, key, item));
    const seen = await this.revisions.seen(item.id);
    if (opened.revision === null) {
      if (seen > 0 || (await this.revisions.legacyClosed())) {
        throw new VaultItemRollbackError(item.id);
      }
    } else {
      if (opened.revision < seen) throw new VaultItemRollbackError(item.id);
      await this.revisions.raise(item.id, opened.revision);
    }
    return this.toEntry(item, opened.value as T);
  }

  private toEntry<T>(item: VaultItem, value: T): VaultEntry<T> {
    return {
      id: item.id,
      kind: item.kind,
      label: item.label,
      version: item.version,
      value,
      updatedAt: item.updatedAt,
    };
  }

  private async conflictOr(id: string, error: unknown): Promise<unknown> {
    if (!(error instanceof CloudApiError) || error.code !== "version_conflict") return error;
    const raw = error.body?.current as VaultItem | null | undefined;
    return new VaultConflictError(id, raw ? await this.open(raw, await this.key()) : null);
  }
}
vault-revisions.ts The highest revision this device has seen of each item, so the cloud cannot serve back a key you replaced. packages/app/src/account/vault-revisions.ts · 107 lines · last changed 2026-10-22 in 8344f2ee0e
import type { AccountSecureStore } from "./account-secure-store";

// Audit VAULT-11 (docs/security.md › Moving to sealed data): what this device remembers about the
// account's vault items, so a cloud that serves an older ciphertext of an item is caught. An item's
// sealed value carries a revision (vault-client.ts bindItem); the device keeps the highest one it
// opened or wrote per item, and whether it has found every item of the account sealed that way
// (after that, a value with no revision is refused). It names item ids and numbers only.

/** JSON `{ user, items: { [itemId]: revision }, legacyClosed }`, for one account at a time. */
export const ACCOUNT_VAULT_REVISIONS_KEY = "shuriken.vault-revisions";

export interface VaultRevisions {
  /** The highest revision of `id` this device opened or wrote; 0 for none. */
  seen(id: string): Promise<number>;
  /** Keeps `revision` for `id` when it is higher than the one kept. */
  raise(id: string, revision: number): Promise<void>;
  /** True once this device found every item of the account sealed with a revision. */
  legacyClosed(): Promise<boolean>;
  closeLegacy(): Promise<void>;
}

interface RevisionState {
  user: string;
  items: Record<string, number>;
  legacyClosed: boolean;
}

function parseState(raw: string | null, user: string): RevisionState {
  const fresh: RevisionState = { user, items: {}, legacyClosed: false };
  if (!raw) return fresh;
  try {
    const parsed = JSON.parse(raw) as Partial<RevisionState> | null;
    if (!parsed || parsed.user !== user) return fresh;
    const items: Record<string, number> = {};
    for (const [id, revision] of Object.entries(parsed.items ?? {})) {
      if (typeof revision === "number" && Number.isSafeInteger(revision) && revision > 0) {
        items[id] = revision;
      }
    }
    return { user, items, legacyClosed: parsed.legacyClosed === true };
  } catch {
    // A damaged record is no record: nothing is refused for it, the next write replaces it.
    return fresh;
  }
}

/**
 * The record kept in `store` for the signed-in account (`userId`). A record of another account
 * is not read, and replaced on the first write. A store that cannot write keeps the record for
 * this run only: it never refuses more than it would with the record kept.
 */
export function storedVaultRevisions(
  store: Pick<AccountSecureStore, "get" | "set">,
  userId: () => string | null,
): VaultRevisions {
  let loaded: { user: string; state: Promise<RevisionState> } | null = null;
  let writes: Promise<void> = Promise.resolve();
  const state = (): Promise<RevisionState> => {
    const user = userId() ?? "";
    if (loaded?.user !== user) {
      loaded = {
        user,
        state: store
          .get(ACCOUNT_VAULT_REVISIONS_KEY)
          .catch(() => null)
          .then((raw) => parseState(raw, user)),
      };
    }
    return loaded.state;
  };
  const persist = (current: RevisionState): Promise<void> => {
    if (!current.user) return writes;
    const text = JSON.stringify(current);
    writes = writes.then(() => store.set(ACCOUNT_VAULT_REVISIONS_KEY, text)).catch(() => undefined);
    return writes;
  };
  return {
    seen: async (id) => (await state()).items[id] ?? 0,
    raise: async (id, revision) => {
      const current = await state();
      if (!Number.isSafeInteger(revision) || revision <= (current.items[id] ?? 0)) return;
      current.items[id] = revision;
      await persist(current);
    },
    legacyClosed: async () => (await state()).legacyClosed,
    closeLegacy: async () => {
      const current = await state();
      if (current.legacyClosed) return;
      current.legacyClosed = true;
      await persist(current);
    },
  };
}

/** A record for this run only (a client nobody gave a store). */
export function memoryVaultRevisions(): VaultRevisions {
  const values = new Map<string, string>();
  return storedVaultRevisions(
    {
      get: async (key) => values.get(key) ?? null,
      set: async (key, value) => {
        values.set(key, value);
      },
    },
    () => "this-run",
  );
}

Keys made from the vault key

account-key.ts The sign-in key from the password key, the account key (Ed25519) from the vault key, and the signatures it makes over device keys and relay handshakes. packages/relay/src/account-key.ts · 276 lines · last changed 2026-10-24 in 7e40a55744
/**
 * The account key: an Ed25519 key pair every device of an account derives from the vault key,
 * which the cloud never holds (docs/cloud.md › Vault key scheme). It is what lets one device
 * trust another's keys without trusting the cloud that carries them (SECURITY.md › Account key):
 *
 * - **Device keys.** A worker's daemon key and a phone's or browser's push key are signed with it.
 *   A client checks the signature before it uses a key the cloud handed it, so a cloud that swaps
 *   in its own key is refused instead of reading the traffic.
 * - **Relay clients.** A client signs each relay handshake with it, so a daemon admits only
 *   devices that hold the vault key, not anyone the cloud lets onto the relay.
 *
 * Deriving it (rather than storing a second secret) means it rotates with the vault key and needs
 * no new backup. The daemon keeps only the public half.
 */

import nacl from "tweetnacl";
import { fromByteArray, toByteArray } from "base64-js";
import { concatBytes, hkdfSha512, utf8 } from "./crypto.js";

export interface AccountKeyPair {
  /** Ed25519 public key, 32 bytes. */
  publicKey: Uint8Array;
  /** Ed25519 secret key (seed and public key), 64 bytes. */
  secretKey: Uint8Array;
}

const ACCOUNT_KEY_INFO = "shuriken/account-key/v1";
const VAULT_KEY_BYTES = 32;

/** What a binding signature covers; one context per kind of key so one can never pass for another. */
export type DeviceKeyPurpose = "daemon" | "push" | "share" | "webpush";

const BINDING_CONTEXT: Record<DeviceKeyPurpose, string> = {
  daemon: "shuriken/device-key/daemon/v1",
  push: "shuriken/device-key/push/v1",
  /** Round 36 S3: the key a rotated vault key is sealed to (vault-rotation.ts). */
  share: "shuriken/device-key/share/v1",
  /**
   * Round 2 A12: a browser's Web Push subscription keys (`webPushKeyBinding`). A worker's daemon
   * seals a chat push to them (RFC 8291) only when this signature verifies, so a cloud that swaps
   * in its own P-256 key gets nothing it can read.
   */
  webpush: "shuriken/device-key/webpush/v1",
};

/**
 * Round 2 A12: what a browser signs for its push subscription (purpose "webpush"): the P-256 key
 * and the auth secret together (base64url has no "."), so neither can be swapped on its own.
 */
export function webPushKeyBinding(p256dh: string, auth: string): string {
  return `${p256dh}.${auth}`;
}
const RELAY_CLIENT_CONTEXT = "shuriken/relay-client/v1";
/** Audit RELAY-6: the proof with its signing time (`at`) in the signed message. */
const RELAY_CLIENT_TIMED_CONTEXT = "shuriken/relay-client/v2";

/**
 * Audit RELAY-6: how far a timed relay proof (account or guest) may be from the daemon's clock,
 * either way. A hello is signed afresh for every connection, so a real one is seconds old; the
 * window only covers clocks that are off. A recording older than this verifies nowhere, and the
 * daemon's replay memory (30 days, persisted) covers everything inside it.
 */
export const RELAY_PROOF_MAX_AGE_MS = 24 * 60 * 60_000;

/** True when a timed proof's `at` is a time inside the window around `now`. */
export function relayProofTimeLive(at: unknown, now: number): at is number {
  return (
    typeof at === "number" &&
    Number.isSafeInteger(at) &&
    at >= now - RELAY_PROOF_MAX_AGE_MS &&
    at <= now + RELAY_PROOF_MAX_AGE_MS
  );
}

/**
 * What signs for the account on a device: the key pair itself where the vault key is at hand, or
 * a key the platform keeps and will not hand out (a browser's non-extractable WebCrypto Ed25519
 * key in IndexedDB), whose `sign` is asynchronous. Ed25519 is deterministic, so both give the same
 * signature.
 */
export interface AccountSigner {
  publicKey: Uint8Array;
  sign(message: Uint8Array): Uint8Array | Promise<Uint8Array>;
}

/** The 32-byte Ed25519 seed of the account key; a platform key store imports it once. */
export function deriveAccountKeySeed(vaultKey: Uint8Array): Uint8Array {
  if (vaultKey.byteLength !== VAULT_KEY_BYTES) {
    throw new Error(`Vault key must be ${VAULT_KEY_BYTES} bytes`);
  }
  return hkdfSha512(vaultKey, new Uint8Array(0), utf8(ACCOUNT_KEY_INFO), 32);
}

export function deriveAccountKeyPair(vaultKey: Uint8Array): AccountKeyPair {
  const seed = deriveAccountKeySeed(vaultKey);
  const pair = nacl.sign.keyPair.fromSeed(seed);
  seed.fill(0);
  return { publicKey: pair.publicKey, secretKey: pair.secretKey };
}

const AUTH_KEY_INFO = "shuriken/auth-key/v1";
const KEK_BYTES = 32;

/**
 * Round 36 S3: what an app signs in with instead of the password (base64, 32 bytes): HKDF of the
 * vault KEK. The cloud stores only a hash of it; the KEK itself never leaves the device, and the
 * auth key does not give it back (docs/cloud.md › Vault key scheme).
 */
export function deriveAuthKey(kek: Uint8Array): string {
  if (kek.byteLength !== KEK_BYTES) throw new Error(`KEK must be ${KEK_BYTES} bytes`);
  const key = hkdfSha512(kek, new Uint8Array(0), utf8(AUTH_KEY_INFO), 32);
  const out = fromByteArray(key);
  key.fill(0);
  return out;
}

export function accountSigner(pair: AccountKeyPair): AccountSigner {
  return {
    publicKey: pair.publicKey,
    sign: (message) => nacl.sign.detached(message, pair.secretKey),
  };
}

export function exportAccountPublicKey(publicKey: Uint8Array): string {
  if (publicKey.byteLength !== nacl.sign.publicKeyLength) throw new Error("Invalid account key");
  return fromByteArray(publicKey);
}

/** The 32-byte account public key from base64, or null when it is not one. */
export function importAccountPublicKey(base64: string): Uint8Array | null {
  try {
    const bytes = toByteArray(base64);
    if (bytes.byteLength !== nacl.sign.publicKeyLength) return null;
    if (fromByteArray(bytes) !== base64) return null;
    return bytes;
  } catch {
    return null;
  }
}

/**
 * Short, readable form of an account key (or any key) for a person to compare across devices:
 * eight groups of four hex digits of its SHA-512.
 */
export function keyFingerprint(publicKeyB64: string): string {
  const digest = nacl.hash(utf8(publicKeyB64));
  const hex = Array.from(digest.subarray(0, 16), (byte) => byte.toString(16).padStart(2, "0"));
  const groups: string[] = [];
  for (let i = 0; i < hex.length; i += 2) groups.push(`${hex[i]}${hex[i + 1]}`);
  return groups.join(" ");
}

function bindingMessage(purpose: DeviceKeyPurpose, deviceId: string, keyB64: string): Uint8Array {
  return utf8(`${BINDING_CONTEXT[purpose]}\n${deviceId}\n${keyB64}`);
}

/** Signs that `keyB64` is `deviceId`'s key for `purpose`. */
export function signDeviceKey(
  account: AccountKeyPair,
  purpose: DeviceKeyPurpose,
  deviceId: string,
  keyB64: string,
): string {
  return fromByteArray(
    nacl.sign.detached(bindingMessage(purpose, deviceId, keyB64), account.secretKey),
  );
}

/** `signDeviceKey` with any signer (a browser's stored key signs asynchronously). */
export async function signDeviceKeyWith(
  signer: AccountSigner,
  purpose: DeviceKeyPurpose,
  deviceId: string,
  keyB64: string,
): Promise<string> {
  return fromByteArray(await signer.sign(bindingMessage(purpose, deviceId, keyB64)));
}

function decodeSignature(signatureB64: string): Uint8Array | null {
  try {
    const bytes = toByteArray(signatureB64);
    return bytes.byteLength === nacl.sign.signatureLength ? bytes : null;
  } catch {
    return null;
  }
}

/** True only when `signatureB64` is the account's signature binding `keyB64` to `deviceId`. */
export function verifyDeviceKey(input: {
  accountPublicKey: Uint8Array;
  purpose: DeviceKeyPurpose;
  deviceId: string;
  keyB64: string;
  signatureB64: string | null | undefined;
}): boolean {
  if (!input.signatureB64) return false;
  const signature = decodeSignature(input.signatureB64);
  if (!signature) return false;
  return nacl.sign.detached.verify(
    bindingMessage(input.purpose, input.deviceId, input.keyB64),
    signature,
    input.accountPublicKey,
  );
}

function relayClientMessage(
  daemonPublicKey: Uint8Array,
  clientEphemeralPublicKey: Uint8Array,
  at?: number,
): Uint8Array {
  if (at === undefined) {
    return concatBytes(utf8(RELAY_CLIENT_CONTEXT), daemonPublicKey, clientEphemeralPublicKey);
  }
  return concatBytes(
    utf8(`${RELAY_CLIENT_TIMED_CONTEXT}\n${at}\n`),
    daemonPublicKey,
    clientEphemeralPublicKey,
  );
}

/** What a client puts in its `e2ee_hello` to prove it holds the account key. */
export interface RelayAccountProof {
  /** The account public key, base64. */
  key: string;
  /** Ed25519 over the context, the daemon's static key and this handshake's client key. */
  sig: string;
  /**
   * Audit RELAY-6: when it was signed (ms since the epoch), inside the signature (context v2).
   * Absent on a 5.0.0 app's proof and on the first hello to a daemon not yet known to read it.
   */
  at?: number;
}

/** `options.at`: sign the timed (v2) proof, for a daemon known to read it (RELAY-6). */
export async function signRelayClient(
  account: AccountSigner,
  daemonPublicKey: Uint8Array,
  clientEphemeralPublicKey: Uint8Array,
  options?: { at?: number },
): Promise<RelayAccountProof> {
  const at = options?.at;
  if (at !== undefined && !Number.isSafeInteger(at)) throw new Error("Not a proof time");
  return {
    key: exportAccountPublicKey(account.publicKey),
    sig: fromByteArray(
      await account.sign(relayClientMessage(daemonPublicKey, clientEphemeralPublicKey, at)),
    ),
    ...(at !== undefined ? { at } : {}),
  };
}

/**
 * The proven account key (base64), or null when the proof is missing or does not verify. A
 * timed proof (RELAY-6) verifies only inside RELAY_PROOF_MAX_AGE_MS of `options.now`.
 */
export function verifyRelayClient(
  proof: RelayAccountProof | undefined,
  daemonPublicKey: Uint8Array,
  clientEphemeralPublicKey: Uint8Array,
  options?: { now?: number },
): string | null {
  if (!proof) return null;
  const key = importAccountPublicKey(proof.key);
  const signature = decodeSignature(proof.sig);
  if (!key || !signature) return null;
  if (proof.at !== undefined && !relayProofTimeLive(proof.at, options?.now ?? Date.now())) {
    return null;
  }
  return nacl.sign.detached.verify(
    relayClientMessage(daemonPublicKey, clientEphemeralPublicKey, proof.at),
    signature,
    key,
  )
    ? proof.key
    : null;
}
documents-key.ts The documents key, for the few settings your computers read too. packages/relay/src/documents-key.ts · 100 lines · last changed 2026-10-08 in e64d0d975b
/**
 * Round 36 S2: the documents key — a 32-byte key every device of an account derives from the vault
 * key (which the cloud never holds), for the few account documents a worker's daemon must read or
 * write without the vault key itself: the shared usage readings, the notification settings
 * (muted projects), the AI accounts' health (emails, labels). Apps hand it to their workers'
 * daemons (a desktop's own daemon with the worker session; a server through the relay, with the
 * vault sync), so a daemon opens these and nothing else the vault key seals (projects, pictures,
 * provider sign-ins stay the apps').
 *
 * A sealed document is NaCl secretbox (XSalsa20-Poly1305, a random 24-byte nonce) over
 * `{ slot, value }`: the slot inside binds it to where it is stored, so the cloud cannot move one
 * sealed document into another's place. docs/security.md › What the cloud stores.
 */

import nacl from "tweetnacl";
import { fromByteArray, toByteArray } from "base64-js";
import { hkdfSha512, secretboxOpen, secretboxSeal, utf8 } from "./crypto.js";

const DOCUMENTS_KEY_INFO = "shuriken/documents-key/v1";
const VAULT_KEY_BYTES = 32;
export const DOCUMENTS_KEY_BYTES = 32;

/** A sealed document as the cloud stores it. */
export interface SealedDocument {
  v: 1;
  nonce: string;
  box: string;
}

export function deriveDocumentsKey(vaultKey: Uint8Array): Uint8Array {
  if (vaultKey.byteLength !== VAULT_KEY_BYTES) {
    throw new Error(`Vault key must be ${VAULT_KEY_BYTES} bytes`);
  }
  return hkdfSha512(vaultKey, new Uint8Array(0), utf8(DOCUMENTS_KEY_INFO), DOCUMENTS_KEY_BYTES);
}

export function exportDocumentsKey(key: Uint8Array): string {
  return fromByteArray(key);
}

/** The key from its base64 form, or null when it is not one. */
export function importDocumentsKey(base64: unknown): Uint8Array | null {
  if (typeof base64 !== "string" || base64.length === 0 || base64.length > 64) return null;
  try {
    const key = toByteArray(base64);
    return key.byteLength === DOCUMENTS_KEY_BYTES ? key : null;
  } catch {
    return null;
  }
}

export function isSealedDocument(value: unknown): value is SealedDocument {
  if (typeof value !== "object" || value === null) return false;
  const record = value as Record<string, unknown>;
  return record.v === 1 && typeof record.nonce === "string" && typeof record.box === "string";
}

export function sealDocument(key: Uint8Array, slot: string, value: unknown): SealedDocument {
  const nonce = nacl.randomBytes(nacl.secretbox.nonceLength);
  const box = secretboxSeal(utf8(JSON.stringify({ slot, value })), nonce, key);
  return { v: 1, nonce: fromByteArray(nonce), box: fromByteArray(box) };
}

export class SealedDocumentError extends Error {
  constructor(readonly slot: string) {
    super(`The sealed document for ${slot} does not open with this key`);
    this.name = "SealedDocumentError";
  }
}

/** Opens a document sealed for `slot`; throws SealedDocumentError on another key, slot or a change. */
export function openDocument(key: Uint8Array, slot: string, sealed: SealedDocument): unknown {
  let opened: Uint8Array | null = null;
  try {
    opened = secretboxOpen(toByteArray(sealed.box), toByteArray(sealed.nonce), key);
  } catch {
    opened = null;
  }
  if (!opened) throw new SealedDocumentError(slot);
  let inner: { slot?: unknown; value?: unknown };
  try {
    inner = JSON.parse(new TextDecoder().decode(opened)) as { slot?: unknown; value?: unknown };
  } catch {
    throw new SealedDocumentError(slot);
  }
  if (inner?.slot !== slot) throw new SealedDocumentError(slot);
  return inner.value;
}

/** A sealed document in one string (a database column): `d1.<nonce>.<box>`. */
export function sealDocumentString(key: Uint8Array, slot: string, value: unknown): string {
  const sealed = sealDocument(key, slot, value);
  return `d1.${sealed.nonce}.${sealed.box}`;
}

export function openDocumentString(key: Uint8Array, slot: string, sealed: string): unknown {
  const [version, nonce, box] = sealed.split(".");
  if (version !== "d1" || !nonce || !box) throw new SealedDocumentError(slot);
  return openDocument(key, slot, { v: 1, nonce, box });
}
sealed-settings.ts Your settings, sealed with the vault key or the documents key before they reach the cloud. packages/app/src/account/sealed-settings.ts · 177 lines · last changed 2026-10-25 in 1c29914db2
import {
  isSealedDocument,
  openDocument,
  sealDocument,
  type SealedDocument,
} from "@shuriken/client";
import { VaultItemDecryptError, decryptVaultValue, encryptVaultValue } from "./vault-crypto";
import type { VaultCryptoBackend } from "./vault-crypto-backend-types";
import { VaultLockedError } from "./vault-client";

// Round 36 S2 (docs/security.md): the account documents only the apps read are sealed with the
// account's vault key before they reach the cloud, so the cloud — its operator, a database leak —
// holds ciphertext for them. `notifications`, which the workers' daemons read too (muted
// projects), is sealed with the account's documents key instead (relay documents-key.ts; every
// worker gets it from an app), with only the owner-alert switches the Hub reads left plain next to
// it. `usage-readings` the daemons seal themselves. docs/security.md says why.
//
// Envelope: `{ sealed: { v: 1, iv, ciphertext }, ...plain }`, where the ciphertext opens to
// `{ key, value }` (the key inside binds a document to its slot: the cloud cannot swap two). The
// only plaintext next to it is what the Hub itself reads: `preferences` keeps `entries.language`,
// the language its pushes are worded in.
//
// Security audit (v5.1.0): a plaintext document in one of these slots is refused (a decrypt error
// with the key, VaultLockedError without it), never applied: the sealing is the documents'
// integrity as well as their secrecy, and anyone who could write /api/settings could otherwise
// choose what every device applies (a new chat's mode). The device's own next write replaces it.

/** The account documents sealed with the vault key. */
export const SEALED_SETTING_KEYS: ReadonlySet<string> = new Set([
  "preferences",
  "new-chat",
  "appearance",
  "device-settings",
  // G-12: the account's prompt snippets (snippets/; protocol snippets/snippet.ts).
  "prompt-snippets",
]);

/** The account documents sealed with the documents key, which the workers' daemons also hold. */
export const WORKER_SEALED_SETTING_KEYS: ReadonlySet<string> = new Set(["notifications"]);

/**
 * The events the Hub raises owner alerts for itself (cloud lib/owner-alerts.ts
 * ALERT_SETTING_EVENT): their switches stay readable next to the sealed notification settings,
 * so the Hub knows whether to push. Booleans and a sound name; no project, no device.
 */
const HUB_ALERT_EVENTS = [
  "worker_offline",
  "update_failed",
  "source_signed_out",
  // Round 37 #29: a new sign-in to the account.
  "new_sign_in",
] as const;

const ENVELOPE_VERSION = 1;

/** `sealed` (vault key) or `box` (documents key), and what stays readable next to it. */
export interface SealedSettingEnvelope {
  sealed?: { v: number; iv: string; ciphertext: string };
  box?: SealedDocument;
  [plain: string]: unknown;
}

export function isSealedSetting(value: unknown): value is SealedSettingEnvelope {
  if (typeof value !== "object" || value === null) return false;
  if (isSealedDocument((value as { box?: unknown }).box)) return true;
  const sealed = (value as { sealed?: unknown }).sealed;
  return (
    typeof sealed === "object" &&
    sealed !== null &&
    typeof (sealed as { iv?: unknown }).iv === "string" &&
    typeof (sealed as { ciphertext?: unknown }).ciphertext === "string"
  );
}

/** What stays readable next to the ciphertext: only what the cloud itself must read. */
export function plainSettingMetadata(
  key: string,
  value: Record<string, unknown>,
): Record<string, unknown> {
  if (key === "notifications") {
    const events = (value.events ?? {}) as Record<string, unknown>;
    const kept: Record<string, unknown> = {};
    for (const event of HUB_ALERT_EVENTS) {
      const entry = events[event] as Record<string, unknown> | undefined;
      if (!entry) continue;
      kept[event] = { desktop: entry.desktop, phone: entry.phone, sound: entry.sound };
    }
    return { events: kept };
  }
  if (key !== "preferences") return {};
  const language = (value.entries as { language?: unknown } | undefined)?.language;
  return language === undefined ? {} : { entries: { language } };
}

export interface OpenedSetting {
  value: Record<string, unknown> | null;
}

export interface SettingsSealer {
  seals(key: string): boolean;
  /** Whether this device holds the key right now. */
  canSeal(): boolean;
  /** Throws VaultLockedError without the key: a sealed document is never sent in plaintext. */
  seal(key: string, value: Record<string, unknown>): Promise<SealedSettingEnvelope>;
  /** Throws VaultLockedError for a sealed document without the key. */
  open(key: string, stored: Record<string, unknown> | null): Promise<OpenedSetting>;
}

export function createSettingsSealer(deps: {
  crypto: VaultCryptoBackend;
  vaultKey: () => Uint8Array | null;
  /** The documents key (derived from the vault key) for WORKER_SEALED_SETTING_KEYS. */
  documentsKey: () => Uint8Array | null;
}): SettingsSealer {
  const requireKey = (): Uint8Array => {
    const key = deps.vaultKey();
    if (!key) throw new VaultLockedError();
    return key;
  };
  const requireDocumentsKey = (): Uint8Array => {
    const key = deps.documentsKey();
    if (!key) throw new VaultLockedError();
    return key;
  };
  return {
    seals: (key) => SEALED_SETTING_KEYS.has(key) || WORKER_SEALED_SETTING_KEYS.has(key),
    canSeal: () => deps.vaultKey() !== null,
    async seal(key, value) {
      if (WORKER_SEALED_SETTING_KEYS.has(key)) {
        const box: SealedDocument = sealDocument(requireDocumentsKey(), key, value);
        return { ...plainSettingMetadata(key, value), box };
      }
      const { iv, ciphertext } = await encryptVaultValue(deps.crypto, requireKey(), {
        key,
        value,
      });
      return {
        ...plainSettingMetadata(key, value),
        sealed: { v: ENVELOPE_VERSION, iv, ciphertext },
      };
    },
    async open(key, stored) {
      if (stored === null) return { value: null };
      if (!isSealedSetting(stored)) {
        // Never applied: proves nothing about who wrote it.
        if (WORKER_SEALED_SETTING_KEYS.has(key)) requireDocumentsKey();
        else requireKey();
        throw new VaultItemDecryptError(`setting:${key}`);
      }
      const { box, sealed } = stored;
      if (isSealedDocument(box)) {
        let inner: unknown;
        try {
          inner = openDocument(requireDocumentsKey(), key, box);
        } catch (error) {
          if (error instanceof VaultLockedError) throw error;
          throw new VaultItemDecryptError(`setting:${key}`);
        }
        if (typeof inner !== "object" || inner === null) {
          throw new VaultItemDecryptError(`setting:${key}`);
        }
        return { value: inner as Record<string, unknown> };
      }
      if (!sealed) throw new VaultItemDecryptError(`setting:${key}`);
      const opened = await decryptVaultValue(deps.crypto, requireKey(), {
        id: `setting:${key}`,
        iv: sealed.iv,
        ciphertext: sealed.ciphertext,
      });
      const inner = opened as { key?: unknown; value?: unknown } | null;
      if (inner?.key !== key || typeof inner.value !== "object" || inner.value === null) {
        throw new VaultItemDecryptError(`setting:${key}`);
      }
      return { value: inner.value as Record<string, unknown> };
    },
  };
}
pictures-key.ts The pictures key, for your profile picture and project pictures. packages/app/src/account/pictures-key.ts · 79 lines · last changed 2026-10-19 in 84f17b8819
import nacl from "tweetnacl";
import { utf8Encode } from "./base64";

// Round 59 (the owner: "online they still show the default ones"): the key every picture of the
// account — his profile picture and every project's — is sealed with. It is derived from the vault
// key (HKDF-SHA-512, as relay documents-key.ts derives the documents key), so every device holding
// the vault key has it, and it opens pictures only: a browser tab without the vault key (every
// visit to shuriken.sh/app after the one that signed in) is handed this key alone, sealed to the
// browser's receiver (picture-copies.ts), and never the vault key. docs/security.md › R2.

const PICTURES_KEY_INFO = "shuriken/pictures-key/v1";
const PICTURES_KEY_ID_INFO = "shuriken/pictures-key-id/v1";
export const PICTURES_KEY_BYTES = 32;
/** A key's id travels in every sealed picture's header: 8 bytes, as 16 hex digits. */
export const PICTURES_KEY_ID_BYTES = 8;
const HMAC_BLOCK = 128;

export interface PicturesKey {
  /** 16 hex digits: which pictures key sealed a picture (pictures sealed before a rotation). */
  id: string;
  key: Uint8Array;
}

function hmacSha512(key: Uint8Array, message: Uint8Array): Uint8Array {
  const block = new Uint8Array(HMAC_BLOCK);
  block.set(key.byteLength > HMAC_BLOCK ? nacl.hash(key) : key);
  const inner = new Uint8Array(HMAC_BLOCK + message.byteLength);
  const outer = new Uint8Array(HMAC_BLOCK + 64);
  for (let i = 0; i < HMAC_BLOCK; i += 1) {
    inner[i] = block[i]! ^ 0x36;
    outer[i] = block[i]! ^ 0x5c;
  }
  inner.set(message, HMAC_BLOCK);
  outer.set(nacl.hash(inner), HMAC_BLOCK);
  return nacl.hash(outer);
}

/** RFC 5869 HKDF-SHA-512 for one block (relay crypto.ts hkdfSha512, the same bytes). */
function hkdfSha512(ikm: Uint8Array, info: string, length: number): Uint8Array {
  const prk = hmacSha512(new Uint8Array(64), ikm);
  const infoBytes = utf8Encode(info);
  const message = new Uint8Array(infoBytes.byteLength + 1);
  message.set(infoBytes);
  message[infoBytes.byteLength] = 1;
  const okm = hmacSha512(prk, message).slice(0, length);
  prk.fill(0);
  return okm;
}

function hex(bytes: Uint8Array): string {
  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}

/** The pictures key and its id, from a key's 32 bytes. */
export function picturesKeyFromBytes(key: Uint8Array): PicturesKey {
  if (key.byteLength !== PICTURES_KEY_BYTES) throw new Error("pictures key must be 32 bytes");
  return {
    id: hex(hkdfSha512(key, PICTURES_KEY_ID_INFO, PICTURES_KEY_ID_BYTES)),
    key: new Uint8Array(key),
  };
}

/** The account's pictures key for a vault key (the current one, or one from before a rotation). */
export function derivePicturesKey(vaultKey: Uint8Array): PicturesKey {
  return picturesKeyFromBytes(hkdfSha512(vaultKey, PICTURES_KEY_INFO, PICTURES_KEY_BYTES));
}

/** The id as the 8 bytes a sealed picture's header carries. */
export function picturesKeyIdBytes(id: string): Uint8Array {
  const out = new Uint8Array(PICTURES_KEY_ID_BYTES);
  for (let i = 0; i < PICTURES_KEY_ID_BYTES; i += 1) {
    out[i] = Number.parseInt(id.slice(i * 2, i * 2 + 2), 16);
  }
  return out;
}

export function picturesKeyIdFromBytes(bytes: Uint8Array): string {
  return hex(bytes.subarray(0, PICTURES_KEY_ID_BYTES));
}
picture-copies.ts A browser's own P-256 key, and the pictures key sealed to it, so a browser tab can show pictures without the vault key. packages/app/src/account/picture-copies.ts · 413 lines · last changed 2026-10-21 in 4ebdcc7746
import type { AccountSigner } from "@shuriken/client";
import nacl from "tweetnacl";
import { base64ToBytes, bytesToBase64, utf8Decode, utf8Encode } from "./base64";
import type { CloudApi } from "./cloud-api";
import type { PictureReceiverKeys } from "./kept-picture-store";
import { PICTURES_KEY_BYTES, type PicturesKey, picturesKeyFromBytes } from "./pictures-key";
import { errorLogText } from "@/utils/error-log-text";

// Round 56 #25 (the owner on shuriken.sh: "the profile picture still doesn't show"). The picture is
// sealed with the vault key (round 36 S2) and a browser keeps that key for its tab only (S3), so
// every visit after the one that signed in drew his monogram. Round 52 kept the picture a tab had
// opened, but his tabs never opened it: none had the key. Now each browser keeps a receiver — a
// P-256 key pair it cannot export (kept-picture-store.web.ts) — and publishes the public half
// signed with the account key. Any device holding the vault key checks that signature and seals
// the picture to it; the cloud stores the copy and can open neither it nor make a receiver of its
// own (it has no account key). docs/security.md › R2.

/** What the account key signs: this receiver key is this device's (relay account-key.ts's form). */
const RECEIVER_CONTEXT = "shuriken/device-key/picture/v1";
const COPY_INFO = "shuriken/picture-copy/v1";
const RECEIVER_KEY_BYTES = 65;
const IV_BYTES = 12;

const buffer = (bytes: Uint8Array): Uint8Array<ArrayBuffer> => new Uint8Array(bytes);

export function receiverBinding(deviceId: string, keyB64: string): Uint8Array {
  return utf8Encode(`${RECEIVER_CONTEXT}\n${deviceId}\n${keyB64}`);
}

/** Whether the account key signed `keyB64` as `deviceId`'s receiver. */
export function receiverIsSigned(input: {
  accountPublicKey: Uint8Array;
  deviceId: string;
  key: string;
  signature: string;
}): boolean {
  try {
    const signature = base64ToBytes(input.signature);
    if (signature.byteLength !== nacl.sign.signatureLength) return false;
    return nacl.sign.detached.verify(
      receiverBinding(input.deviceId, input.key),
      signature,
      input.accountPublicKey,
    );
  } catch {
    return false;
  }
}

/** The AES-GCM key both ends derive: ECDH, then HKDF over both public points. */
async function copyKey(
  subtle: SubtleCrypto,
  privateKey: CryptoKey,
  peer: Uint8Array,
  ephemeral: Uint8Array,
  receiver: Uint8Array,
  usage: KeyUsage,
  info: string = COPY_INFO,
): Promise<CryptoKey> {
  const peerKey = await subtle.importKey(
    "raw",
    buffer(peer),
    { name: "ECDH", namedCurve: "P-256" },
    false,
    [],
  );
  const shared = await subtle.deriveBits({ name: "ECDH", public: peerKey }, privateKey, 256);
  const material = await subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]);
  const salt = new Uint8Array(ephemeral.byteLength + receiver.byteLength);
  salt.set(ephemeral);
  salt.set(receiver, ephemeral.byteLength);
  return subtle.deriveKey(
    { name: "HKDF", hash: "SHA-256", salt, info: buffer(utf8Encode(info)) },
    material,
    { name: "AES-GCM", length: 256 },
    false,
    [usage],
  );
}

/** `ephemeral[65] || iv[12] || AES-GCM([type length][type][image])`, for `receiver` only. */
export async function sealPictureCopy(
  subtle: SubtleCrypto,
  receiver: Uint8Array,
  picture: { mimeType: string; bytes: Uint8Array },
): Promise<Uint8Array> {
  const pair = await subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, false, [
    "deriveBits",
  ]);
  const ephemeral = new Uint8Array(await subtle.exportKey("raw", pair.publicKey));
  const key = await copyKey(subtle, pair.privateKey, receiver, ephemeral, receiver, "encrypt");
  const type = utf8Encode(picture.mimeType);
  const payload = new Uint8Array(1 + type.length + picture.bytes.length);
  payload[0] = type.length;
  payload.set(type, 1);
  payload.set(picture.bytes, 1 + type.length);
  const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_BYTES));
  const sealed = new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv }, key, payload));
  const out = new Uint8Array(ephemeral.length + IV_BYTES + sealed.length);
  out.set(ephemeral);
  out.set(iv, ephemeral.length);
  out.set(sealed, ephemeral.length + IV_BYTES);
  return out;
}

/** The picture as a data URI, opened with this browser's receiver; null when it does not open. */
export async function openPictureCopy(
  subtle: SubtleCrypto,
  receiver: PictureReceiverKeys,
  sealed: Uint8Array,
): Promise<string | null> {
  if (sealed.length <= RECEIVER_KEY_BYTES + IV_BYTES) return null;
  const ephemeral = sealed.subarray(0, RECEIVER_KEY_BYTES);
  const iv = sealed.subarray(RECEIVER_KEY_BYTES, RECEIVER_KEY_BYTES + IV_BYTES);
  try {
    const key = await copyKey(
      subtle,
      receiver.privateKey,
      ephemeral,
      ephemeral,
      receiver.publicKey,
      "decrypt",
    );
    const payload = new Uint8Array(
      await subtle.decrypt(
        { name: "AES-GCM", iv: buffer(iv) },
        key,
        buffer(sealed.subarray(RECEIVER_KEY_BYTES + IV_BYTES)),
      ),
    );
    const typeLength = payload[0] ?? 0;
    const mimeType = utf8Decode(payload.subarray(1, 1 + typeLength));
    if (!/^image\/(png|jpeg|webp)$/.test(mimeType)) return null;
    return `data:${mimeType};base64,${bytesToBase64(payload.subarray(1 + typeLength))}`;
  } catch {
    return null;
  }
}

/** What the account picture's reader needs; every call is best effort and never throws. */
export interface PictureCopies {
  /** A locked tab: publishes this browser's receiver (once per receiver key and tab). */
  publish(): Promise<void>;
  /** A locked tab: its copy of the picture, opened, or null. */
  read(): Promise<string | null>;
  /** A device that opened the picture with the vault key: seals it for every receiver behind. */
  sealForReceivers(): Promise<void>;
}

export interface PictureCopiesDeps {
  api: Pick<
    CloudApi,
    | "getMyPicture"
    | "putPictureReceiver"
    | "listPictureReceivers"
    | "putPictureCopy"
    | "getPictureCopy"
  >;
  /** This device's id on the account (the session's). */
  deviceId: string;
  subtle: SubtleCrypto | undefined;
  /** This browser's receiver (null: a platform that keeps none). */
  receiver: (create: boolean) => Promise<PictureReceiverKeys | null>;
  /** The account key this device proves the account with (a browser's kept one while locked). */
  signer: () => AccountSigner | null;
  /** The account's current public key, only while the vault key is open here. */
  accountPublicKey: () => Uint8Array | null;
}

/** Receivers this tab already published: one PUT per key, not one per read. */
const published = new Set<string>();

/** Copy-upload failures already logged, by kind and cause. */
const reportedFailures = new Set<string>();

/**
 * A sealed copy's upload failed. A stale receiver (412) is routine: the next pass seals to the
 * new one. Anything else is logged once per kind and cause: the cloud's CORS preflight refused
 * every desktop upload for a release and nothing said so (5.1.0 audit).
 */
function reportCopyFailure(kind: "picture copy" | "pictures key copy", error: unknown): void {
  if ((error as { status?: unknown } | null)?.status === 412) return;
  const cause = errorLogText(error);
  const id = `${kind}:${cause}`;
  if (reportedFailures.has(id)) return;
  reportedFailures.add(id);
  console.warn(`[pictures] ${kind} upload failed:`, cause);
}

export function createPictureCopies(deps: PictureCopiesDeps): PictureCopies {
  return {
    async publish() {
      try {
        const signer = deps.signer();
        if (!signer) return;
        const receiver = await deps.receiver(true);
        if (!receiver) return;
        const key = bytesToBase64(receiver.publicKey);
        const id = `${deps.deviceId}:${key}:${bytesToBase64(signer.publicKey)}`;
        if (published.has(id)) return;
        const signature = bytesToBase64(await signer.sign(receiverBinding(deps.deviceId, key)));
        await deps.api.putPictureReceiver({ key, signature });
        published.add(id);
      } catch {
        // Offline, or a cloud from before receivers: the next read publishes it.
      }
    },
    async read() {
      try {
        const subtle = deps.subtle;
        const receiver = subtle ? await deps.receiver(false) : null;
        if (!subtle || !receiver) return null;
        const sealed = await deps.api.getPictureCopy();
        return sealed ? await openPictureCopy(subtle, receiver, sealed) : null;
      } catch {
        return null;
      }
    },
    async sealForReceivers() {
      try {
        const subtle = deps.subtle;
        const accountPublicKey = deps.accountPublicKey();
        if (!subtle || !accountPublicKey) return;
        const { picture, receivers } = await deps.api.listPictureReceivers();
        const behind = receivers.filter(
          (receiver) =>
            !receiver.current &&
            receiverIsSigned({ accountPublicKey, ...receiver }) &&
            base64ToBytes(receiver.key).byteLength === RECEIVER_KEY_BYTES,
        );
        if (!picture || behind.length === 0) return;
        // Read after the list: a picture changed in between is refused as stale, never mislabeled.
        const opened = await deps.api.getMyPicture();
        if (!opened) return;
        const bytes = base64ToBytes(opened.base64);
        for (const receiver of behind) {
          const sealed = await sealPictureCopy(subtle, base64ToBytes(receiver.key), {
            mimeType: opened.mimeType,
            bytes,
          });
          await deps.api
            .putPictureCopy(receiver.deviceId, sealed, { picture, key: receiver.key })
            .catch((error: unknown) => reportCopyFailure("picture copy", error));
        }
      } catch {
        // Offline, locked meanwhile, or a cloud from before receivers: the next read seals.
      }
    },
  };
}

/**
 * Round 59 (the owner: "online they still show the default ones", for his profile picture and
 * every project's). A copy of the picture itself reached a browser only after a device holding the
 * vault key sealed that one picture to it, and project pictures had no copies at all. Now each
 * receiver is handed the account's pictures key (pictures-key.ts) once:
 * `ephemeral[65] || iv[12] || AES-GCM(key id[8] || key[32])`, the same ECDH and HKDF as a picture
 * copy under its own info. With it a locked tab opens every picture of the account as it is in
 * the cloud now, and a picture changed later needs nothing sealed again. The tab that holds the
 * vault key (the sign-in, an unlock) seals it to its own receiver, so its browser's later tabs need
 * no other device online; any device with the vault key seals it for the receivers still behind.
 */
const KEY_COPY_INFO = "shuriken/pictures-key-copy/v1";
const KEY_ID_BYTES = 8;

export async function sealPicturesKeyCopy(
  subtle: SubtleCrypto,
  receiver: Uint8Array,
  key: PicturesKey,
): Promise<Uint8Array> {
  const pair = await subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, false, [
    "deriveBits",
  ]);
  const ephemeral = new Uint8Array(await subtle.exportKey("raw", pair.publicKey));
  const aes = await copyKey(
    subtle,
    pair.privateKey,
    receiver,
    ephemeral,
    receiver,
    "encrypt",
    KEY_COPY_INFO,
  );
  const payload = new Uint8Array(KEY_ID_BYTES + PICTURES_KEY_BYTES);
  for (let i = 0; i < KEY_ID_BYTES; i += 1) {
    payload[i] = Number.parseInt(key.id.slice(i * 2, i * 2 + 2), 16);
  }
  payload.set(key.key, KEY_ID_BYTES);
  const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_BYTES));
  const sealed = new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv }, aes, payload));
  payload.fill(0);
  const out = new Uint8Array(ephemeral.length + IV_BYTES + sealed.length);
  out.set(ephemeral);
  out.set(iv, ephemeral.length);
  out.set(sealed, ephemeral.length + IV_BYTES);
  return out;
}

/** The pictures key sealed to this browser's receiver, opened; null when it does not open. */
export async function openPicturesKeyCopy(
  subtle: SubtleCrypto,
  receiver: PictureReceiverKeys,
  sealed: Uint8Array,
): Promise<PicturesKey | null> {
  if (sealed.length <= RECEIVER_KEY_BYTES + IV_BYTES) return null;
  const ephemeral = sealed.subarray(0, RECEIVER_KEY_BYTES);
  const iv = sealed.subarray(RECEIVER_KEY_BYTES, RECEIVER_KEY_BYTES + IV_BYTES);
  try {
    const aes = await copyKey(
      subtle,
      receiver.privateKey,
      ephemeral,
      ephemeral,
      receiver.publicKey,
      "decrypt",
      KEY_COPY_INFO,
    );
    const payload = new Uint8Array(
      await subtle.decrypt(
        { name: "AES-GCM", iv: buffer(iv) },
        aes,
        buffer(sealed.subarray(RECEIVER_KEY_BYTES + IV_BYTES)),
      ),
    );
    if (payload.byteLength !== KEY_ID_BYTES + PICTURES_KEY_BYTES) return null;
    const key = picturesKeyFromBytes(payload.subarray(KEY_ID_BYTES));
    const id = Array.from(payload.subarray(0, KEY_ID_BYTES), (byte) =>
      byte.toString(16).padStart(2, "0"),
    ).join("");
    payload.fill(0);
    // The id inside is the key's own: a copy whose key and id disagree is not opened.
    return id === key.id ? key : null;
  } catch {
    return null;
  }
}

export interface PicturesKeyCopies {
  /** A browser: publishes its receiver, signed with the account key; whether one is published. */
  publish(): Promise<boolean>;
  /** A browser: the pictures key sealed to its receiver, opened; null when there is none yet. */
  read(): Promise<PicturesKey | null>;
  /** A device holding the vault key: seals `key` for every receiver still behind; how many. */
  sealForReceivers(key: PicturesKey): Promise<number>;
}

export type PicturesKeyCopiesDeps = Omit<PictureCopiesDeps, "api"> & {
  api: Pick<
    CloudApi,
    "putPictureReceiver" | "listPictureReceivers" | "putPicturesKeyCopy" | "getPicturesKeyCopy"
  >;
};

export function createPicturesKeyCopies(deps: PicturesKeyCopiesDeps): PicturesKeyCopies {
  return {
    async publish() {
      const signer = deps.signer();
      if (!signer || !deps.subtle) return false;
      const receiver = await deps.receiver(true);
      if (!receiver) return false;
      const key = bytesToBase64(receiver.publicKey);
      const id = `${deps.deviceId}:${key}:${bytesToBase64(signer.publicKey)}`;
      if (published.has(id)) return true;
      const signature = bytesToBase64(await signer.sign(receiverBinding(deps.deviceId, key)));
      await deps.api.putPictureReceiver({ key, signature });
      published.add(id);
      return true;
    },
    async read() {
      const subtle = deps.subtle;
      const receiver = subtle ? await deps.receiver(false) : null;
      if (!subtle || !receiver) return null;
      const sealed = await deps.api.getPicturesKeyCopy();
      return sealed ? openPicturesKeyCopy(subtle, receiver, sealed) : null;
    },
    async sealForReceivers(key) {
      const subtle = deps.subtle;
      const accountPublicKey = deps.accountPublicKey();
      if (!subtle || !accountPublicKey) return 0;
      const { receivers } = await deps.api.listPictureReceivers();
      let sealed = 0;
      for (const receiver of receivers) {
        const behind =
          !receiver.keyCopy ||
          receiver.keyCopy.key !== receiver.key ||
          receiver.keyCopy.keyId !== key.id;
        if (!behind) continue;
        // Only a receiver the account key signed: the cloud cannot slip in one of its own.
        if (!receiverIsSigned({ accountPublicKey, ...receiver })) continue;
        const point = base64ToBytes(receiver.key);
        if (point.byteLength !== RECEIVER_KEY_BYTES) continue;
        const copy = await sealPicturesKeyCopy(subtle, point, key);
        try {
          await deps.api.putPicturesKeyCopy(receiver.deviceId, copy, {
            receiverKey: receiver.key,
            keyId: key.id,
          });
          sealed += 1;
        } catch (error) {
          // Stale (the receiver changed meanwhile: the next pass seals to the new one) or offline.
          reportCopyFailure("pictures key copy", error);
        }
      }
      return sealed;
    },
  };
}

/** Tests: forget which receivers this tab published, and which upload failures it logged. */
export function resetPublishedReceiversForTests(): void {
  published.clear();
  reportedFailures.clear();
}

The relay channel

encrypted-channel.tsApache-2.0 The handshake between an app and a computer, the per-direction session keys, frame counters and padding. packages/relay/src/encrypted-channel.ts · 2,120 lines · last changed 2026-10-24 in 1baaa576d1
/// <reference lib="dom" />
/**
 * Encrypted channel that wraps a WebSocket-like transport.
 *
 * Handles ECDH handshake and encrypts/decrypts all messages.
 * Works identically for daemon and client sides.
 */

import {
  generateKeyPair,
  exportPublicKey,
  importPublicKey,
  deriveSharedKey,
  deriveSessionKeys,
  encrypt,
  encryptWithCounter,
  decrypt,
  hkdfSha512,
  hmacSha512,
  openWithCounter,
  secretboxSeal,
  utf8,
  type KeyPair,
  type SharedKey,
} from "./crypto.js";
import {
  signRelayClient,
  verifyRelayClient,
  type AccountSigner,
  type RelayAccountProof,
} from "./account-key.js";
import {
  PROJECT_SHARE_ID_PATTERN,
  signRelayGuest,
  verifyRelayGuest,
  type ProvenGuest,
  type RelayGuestProof,
} from "./guest-proof.js";
import {
  isRelayLoanProofShape,
  signRelayLoan,
  verifyRelayLoan,
  type ProvenLoan,
  type RelayLoanProof,
} from "./loan-proof.js";
import type { SignedLoanDelegation } from "./loan-delegation.js";
import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js";

export interface Transport {
  send(data: string | ArrayBuffer): void | Promise<void>;
  close(code?: number, reason?: string): void;
  onmessage: ((message: TransportMessage) => void) | null;
  onclose: ((code: number, reason: string) => void) | null;
  onerror: ((error: Error) => void) | null;
}

export interface TransportMessage {
  data: string | ArrayBuffer;
  isBinary: boolean;
}

export interface EncryptedChannelEvents {
  onopen?: () => void;
  onmessage?: (data: string | ArrayBuffer) => void;
  onclose?: (code: number, reason: string) => void;
  onerror?: (error: Error) => void;
}

type ChannelState = "connecting" | "handshaking" | "open" | "closed";

interface EncryptedChannelOptions {
  /**
   * If set, the channel can validate repeated plaintext `{type:"e2ee_hello"}`
   * messages even after it is open.
   *
   * This is useful for robustness when the client retries the handshake
   * (e.g., it didn't observe the daemon's `{type:"e2ee_ready"}` yet). In that case,
   * the daemon should re-send `{type:"e2ee_ready"}` without changing keys.
   */
  daemonKeyPair?: KeyPair;
  binaryCiphertext?: boolean;
  /**
   * Client side: text sent before `e2ee_ready` goes out at once, sealed as base64 text, which a
   * daemon in either ciphertext mode reads (`createDaemonChannel` replays frames that arrive
   * behind the hello). The session's own hello then travels with the handshake instead of one
   * round trip after it. Binary waits for the negotiated mode, and anything sent after a waiting
   * frame waits too, so order is kept.
   *
   * Audit RELAY-2: an early frame is sealed under the handshake key, which has no ephemeral part,
   * so a recording opens with a later-stolen daemon key. A frame that carries a credential
   * (`carriesSessionCredential`) therefore always waits for the session keys, and so does any
   * text `mayPipeline` refuses.
   */
  pipelineText?: boolean;
  /** Client side, audit RELAY-2: the caller's own rule for which text may ride early. */
  mayPipeline?: (text: string) => boolean;
  /**
   * W7: every sealed frame travels as a binary WebSocket frame and its first plaintext byte
   * says what it holds (`FrameKind`), so text is not base64'd on the wire (a third more bytes).
   */
  framedPlaintext?: boolean;
  /**
   * Audit RELAY-8: the peer reads padded frames (`framed`), so every frame this side seals after
   * the handshake is padded to its size bucket (`paddedFrameLength`).
   */
  padded?: boolean;
  /**
   * Session keys (SECURITY.md › Session keys): one key per direction and a counter per frame.
   * Set on the daemon at the handshake; on the client it holds what the ready needs to finish.
   */
  session?: SessionKeyState;
  /** The daemon's ephemeral public key, which a retried `e2ee_ready` repeats. */
  daemonEphemeralPublicKeyB64?: string;
  /**
   * Client side, audit RELAY-6: the daemon's static key; a ready that says `timedProof` puts it
   * in `timedProofDaemons`, so later hellos to it carry the timed proof. Audit RELAY-10: and the
   * key its channel level is pinned under (`daemonChannelLevels`).
   */
  timedProofDaemonKeyB64?: string;
  /** Client side, audit RELAY-10: the hello exactly as sent, for the transcript hash. */
  helloText?: string;
  /**
   * Client side, audit RELAY-10: the hello carried an account, guest or loan proof. Every daemon
   * that reads one requires session keys, so a ready without them is refused.
   */
  provedPeer?: boolean;
  /**
   * Client side, audit RELAY-10 (D): a credential (`carriesSessionCredential`) is never sealed
   * without session keys. Every daemon from 5.0.0 on agrees to them when asked.
   */
  credentialsNeedSessionKeys?: boolean;
  /** Daemon side, audit RELAY-10: the ready exactly as sent; a retried ready resends it. */
  readyText?: string;
  /**
   * Item 79: a loan channel. Every frame is binary and of kind `L`; text is refused
   * on send, and a frame of any other kind closes the channel with 4403.
   */
  loan?: boolean;
}

interface SessionKeyState {
  /** Client side, until `e2ee_ready` names the daemon's ephemeral key. */
  pending?: { ephemeral: KeyPair; daemonStaticPublicKey: Uint8Array };
  sendKey?: Uint8Array;
  receiveKey?: Uint8Array;
  sendCounter: number;
  receiveCounter: number;
  /**
   * Daemon side: base64 text frames are the client's frames sealed before it read the ready
   * (`pipelineText`), under the handshake's static key. They are accepted only until the first
   * session frame, and each nonce once, so none can be replayed later in the session.
   */
  earlyNonces?: Set<string>;
  sawSessionFrame?: boolean;
  /**
   * Audit D16, daemon side: early frames from a confirming client (`isConfirmingEarlyNonce`),
   * opened but not yet delivered. They reach the app only once a session frame proves the client
   * is live; a replayed handshake never produces one, so they are dropped with the channel.
   */
  heldEarly?: string[];
  /** Daemon side: the hello asked for `confirm`, so the ready (and a retried one) echoes it. */
  confirm?: boolean;
  /** Daemon side, audit RELAY-10: the hello asked for `transcript`, so a bare confirm is refused. */
  transcript?: boolean;
  /**
   * Daemon side, audit RELAY-10: the transcript hash of the hello as read and the ready as sent,
   * which a confirm frame with a body must carry.
   */
  expectedTranscript?: Uint8Array;
  /** Daemon side, audit RELAY-10: the confirm frame arrived and checked out. */
  confirmed?: boolean;
}

/** Security options a client adds to its handshake. */
export interface ClientChannelSecurity {
  /** Signs the handshake with the account key, so the daemon admits this device (account-key.ts). */
  account?: AccountSigner;
  /**
   * Shared projects (v6.0.0): a member signs the handshake with its own account key for one share
   * (guest-proof.ts). Never beside `account`.
   */
  guest?: { shareId: string; signer: AccountSigner };
  /**
   * Item 79: a borrower's daemon signs the handshake with its delegated device key for one loan
   * (loan-proof.ts), and the channel becomes a loan channel. Never beside `account` or `guest`.
   */
  loan?: { loanId: string; delegation: SignedLoanDelegation; device: AccountSigner };
}

/** Security options a daemon adds to its handshake. */
export interface DaemonChannelSecurity {
  /**
   * Client handshake keys this daemon has already accepted. A client makes a fresh key for every
   * connection, so a repeat is a recorded handshake played again and is refused. Defaults to a
   * cache shared by every channel in the process.
   */
  seenClientKeys?: SeenClientKeys;
  /**
   * Shared projects (v6.0.0): read a member's guest proof. Off unless the listener opts in: guests
   * reach a daemon through the cloud relay only, so the direct (LAN) listener refuses them.
   */
  guests?: boolean;
  /**
   * Item 79: read a borrower daemon's loan proof. Off unless the listener opts in: loans reach the
   * serving daemon through the cloud relay only, so the direct (LAN) listener refuses them.
   */
  loans?: boolean;
  /** Audit RELAY-6: the clock a timed proof is checked against; tests set it. */
  now?: () => number;
}

export interface SeenClientKeys {
  /**
   * True when `key` was new (and is now remembered), false for a repeat. `proven`: the hello's
   * account, guest or loan proof verified, which no relay can make up (audit RELAY-6).
   */
  remember(key: string, proven?: boolean): boolean;
}

/** Audit RELAY-6: one remembered handshake, as a durable store keeps it. */
export interface SeenHandshake {
  /** `handshakeReplayKey`: a digest of the handshake's shared key, never the key itself. */
  key: string;
  proven: boolean;
  /** When the daemon first accepted it, ms since the epoch. */
  at: number;
}

/**
 * Audit RELAY-6: a durable copy of the memory, so a daemon restart does not forget what it
 * accepted (the daemon keeps it in `$SHURIKEN_HOME/seen-handshakes`). A store that throws costs
 * only the durability: the memory keeps working.
 */
export interface SeenHandshakeStore {
  load(): SeenHandshake[];
  add(entry: SeenHandshake): void;
  /** Rewrites the store to exactly `entries` (it drops what the memory forgot). */
  replace(entries: SeenHandshake[]): void;
}

export interface SeenClientKeysOptions {
  /** How long a handshake is remembered. A replay inside it is refused, even after a restart. */
  ttlMs?: number;
  /** Proven handshakes held at most; only real devices make them, so this is a backstop. */
  provenLimit?: number;
  /** Unproven handshakes held at most. Past it the oldest unproven one goes, never a proven one. */
  unprovenLimit?: number;
  now?: () => number;
  store?: SeenHandshakeStore;
}

/** Audit RELAY-6: how long a daemon remembers a handshake it accepted. */
export const SEEN_HANDSHAKE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const SEEN_PROVEN_LIMIT = 100_000;
const SEEN_UNPROVEN_LIMIT = 50_000;
/** A store is rewritten once it holds this many lines beyond twice the live entries. */
const SEEN_STORE_SLACK = 1024;

/**
 * Client handshakes this daemon has accepted, so a recorded one played again is refused.
 *
 * Audit RELAY-6: proven and unproven handshakes are held apart, so hellos a relay makes up (which
 * can never carry a proof that verifies) cannot push a proven one out; entries are kept for
 * `ttlMs`, not dropped by count; and a `store` carries them across a restart.
 */
export function createSeenClientKeys(options: SeenClientKeysOptions | number = {}): SeenClientKeys {
  const opts: SeenClientKeysOptions =
    typeof options === "number" ? { provenLimit: options, unprovenLimit: options } : options;
  const ttlMs = opts.ttlMs ?? SEEN_HANDSHAKE_TTL_MS;
  const now = opts.now ?? Date.now;
  const store = opts.store;
  // Insertion order is acceptance order, so the oldest entry of each is first.
  const proven = new Map<string, number>();
  const unproven = new Map<string, number>();
  const limitOf = (bucket: Map<string, number>): number =>
    bucket === proven
      ? (opts.provenLimit ?? SEEN_PROVEN_LIMIT)
      : (opts.unprovenLimit ?? SEEN_UNPROVEN_LIMIT);
  const expired = (at: number, t: number): boolean => t - at >= ttlMs;
  let storedLines = 0;

  const forgetExpired = (t: number): void => {
    for (const bucket of [proven, unproven]) {
      for (const [key, at] of bucket) {
        if (!expired(at, t)) break;
        bucket.delete(key);
      }
    }
  };
  const entries = (): SeenHandshake[] =>
    [
      ...[...proven].map(([key, at]) => ({ key, proven: true, at })),
      ...[...unproven].map(([key, at]) => ({ key, proven: false, at })),
    ].sort((a, b) => a.at - b.at);
  const rewriteStore = (): void => {
    if (!store) return;
    const live = entries();
    try {
      store.replace(live);
      storedLines = live.length;
    } catch {
      // Durability lost for now; the memory still refuses repeats.
    }
  };
  const add = (bucket: Map<string, number>, key: string, at: number): void => {
    bucket.set(key, at);
    if (bucket.size > limitOf(bucket)) {
      const oldest = bucket.keys().next().value;
      if (oldest !== undefined) bucket.delete(oldest);
    }
  };

  if (store) {
    let loaded: SeenHandshake[] = [];
    try {
      loaded = store.load();
    } catch {
      loaded = [];
    }
    const t = now();
    const valid = loaded
      .filter(
        (entry) =>
          typeof entry?.key === "string" &&
          entry.key.length > 0 &&
          Number.isFinite(entry.at) &&
          !expired(entry.at, t),
      )
      .sort((a, b) => a.at - b.at);
    for (const entry of valid) {
      if (proven.has(entry.key) || unproven.has(entry.key)) continue;
      add(entry.proven === true ? proven : unproven, entry.key, entry.at);
    }
    storedLines = loaded.length;
    if (proven.size + unproven.size < loaded.length) rewriteStore();
  }

  return {
    remember(key, isProven = false) {
      const t = now();
      forgetExpired(t);
      for (const bucket of [proven, unproven]) {
        const at = bucket.get(key);
        if (at === undefined) continue;
        if (!expired(at, t)) return false;
        bucket.delete(key);
      }
      add(isProven ? proven : unproven, key, t);
      if (store) {
        try {
          store.add({ key, proven: isProven, at: t });
          storedLines += 1;
        } catch {
          // As in rewriteStore.
        }
        if (storedLines > 2 * (proven.size + unproven.size) + SEEN_STORE_SLACK) rewriteStore();
      }
      return true;
    },
  };
}

const SEEN_HANDSHAKE_INFO = utf8("shuriken/seen-handshake/v1");

/**
 * Audit RELAY-6: what the memory keys a handshake on: a digest of its shared key, which is what
 * opens its early frames. Every encoding of the same client key (X25519 ignores the top bit, and
 * a few values reduce mod p) gives the same shared key, so a re-encoded recording is still a
 * repeat; the digest is one way, so a stored entry opens nothing.
 */
function handshakeReplayKey(sharedKey: SharedKey): string {
  const digest = hkdfSha512(sharedKey, new Uint8Array(0), SEEN_HANDSHAKE_INFO, 16);
  return arrayBufferToBase64(digest.slice().buffer);
}

let processSeenClientKeys: SeenClientKeys = createSeenClientKeys();

/**
 * Audit RELAY-6: the memory every daemon channel in this process uses unless the caller passes
 * its own. The daemon sets one with a durable store at startup, before it listens.
 */
export function setProcessSeenClientKeys(seen: SeenClientKeys): void {
  processSeenClientKeys = seen;
}

/** Channel options a caller may add to a handshake (the codec its platform has). */
export interface EncryptedChannelCodec {
  /*
   * Audit RELAY-8: there is no `deflate` and no `inflate`. A frame packed before sealing has a
   * length that follows how well its content compresses, which leaks a secret sharing a frame with
   * text an attacker can influence (a page an agent read beside terminal output). Frames are
   * padded instead, and a packed frame (kinds 2 and 3) fails the channel whatever the ready says:
   * the ready is plaintext a relay can write, so an echoed `deflate` the client never asked for
   * (which a 5.0.0 daemon would act on, were the relay to add it to the hello) negotiates nothing.
   */
  /**
   * Client side, audit RELAY-2: which text may be sealed before `e2ee_ready` (under the handshake
   * key, not forward secret). Anything it refuses waits for the session keys. It can only hold
   * back: a frame that carries a credential (`carriesSessionCredential`) waits whatever it says.
   */
  mayPipeline?: (text: string) => boolean;
}

/**
 * Audit RELAY-2: whether `text` is a message with a credential at its top level, the session
 * hello's `auth` (daemon password or local credential) or `device` (a paired device's credential
 * or a pairing token). Such a frame is never sealed under the handshake key alone.
 */
export function carriesSessionCredential(text: string): boolean {
  if (!text.trimStart().startsWith("{")) return false;
  let parsed: unknown;
  try {
    parsed = JSON.parse(text);
  } catch {
    return false;
  }
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false;
  const record = parsed as Record<string, unknown>;
  return (
    (record.auth !== undefined && record.auth !== null) ||
    (record.device !== undefined && record.device !== null)
  );
}

/** First plaintext byte of a framed frame. */
const FrameKind = {
  text: 0,
  binary: 1,
  /**
   * W7's deflate-raw packed text and binary. Audit RELAY-8: never sent, and refused on receipt
   * (`unframe`), even after a ready that echoed `deflate`.
   */
  deflatedText: 2,
  deflatedBinary: 3,
  /**
   * Audit D16: an empty session frame a confirming client sends first, so a daemon holding its
   * early frames knows the client is live. Never delivered; sent only to a daemon that echoed
   * `confirm`, so an older daemon never sees the kind.
   */
  confirm: 4,
  /**
   * Item 79: a loan frame (a preview-tunnel frame of a loan stream, preview-tunnel/protocol.ts).
   * The only kind a loan channel carries, and never on any other channel. ASCII "L".
   */
  loan: 0x4c,
} as const;

/**
 * Audit RELAY-8: a padded frame's kind byte has this bit set and a 4-byte big-endian body length
 * follows it; zero bytes fill the rest of the frame to its size bucket. A receiver reads a padded
 * frame whatever was negotiated, so only the sender needs to know the peer asked for it.
 */
const PADDED_FRAME_FLAG = 0x80;
const PADDED_FRAME_HEADER_BYTES = 5;
const PAD_SMALL_STEP = 256;
const PAD_SMALL_LIMIT = 16 * 1024;
const PAD_LARGE_STEP = 4 * 1024;

/**
 * Audit RELAY-8: the framed plaintext length a padded frame of `unpaddedBytes` (header and body)
 * is sealed at: the next multiple of 256 bytes up to 16 KiB, then of 4 KiB. The relay learns a
 * frame's bucket, not its size.
 */
export function paddedFrameLength(unpaddedBytes: number): number {
  const step = unpaddedBytes <= PAD_SMALL_LIMIT ? PAD_SMALL_STEP : PAD_LARGE_STEP;
  return Math.max(step, Math.ceil(unpaddedBytes / step) * step);
}

/** Item 79: a loan channel closes with this when it receives a frame of any other kind. */
export const LOAN_FRAME_REFUSED_CLOSE_CODE = 4403;
const LOAN_FRAMES_ONLY = "Loan channel carries loan frames only";

/** A frame a loan channel refuses: it closes the channel with LOAN_FRAME_REFUSED_CLOSE_CODE. */
class LoanFrameRefusedError extends Error {
  constructor() {
    super(LOAN_FRAMES_ONLY);
  }
}

/**
 * Audit D16: the first bytes of an early frame's nonce from a client that sends a confirm frame.
 * The nonce is authenticated, so a relay cannot add or remove the mark; a daemon holds marked early
 * frames until a session frame arrives, whatever the (unsigned) hello capabilities say. An older
 * daemon ignores nonce contents, so marked frames still open there. 16 random bytes remain, and a
 * handshake key lives for one connection.
 */
const CONFIRMING_EARLY_NONCE_MARK = new Uint8Array([
  0x53, 0x48, 0x4b, 0x2d, 0x43, 0x4e, 0x46, 0x31,
]);
const NONCE_BYTES = 24;
/** Held early frames past this close the channel: a live client confirms within one round trip. */
const MAX_HELD_EARLY_FRAMES = 1024;

function isConfirmingEarlyNonce(bundle: Uint8Array): boolean {
  if (bundle.byteLength < NONCE_BYTES) return false;
  for (let i = 0; i < CONFIRMING_EARLY_NONCE_MARK.byteLength; i += 1) {
    if (bundle[i] !== CONFIRMING_EARLY_NONCE_MARK[i]) return false;
  }
  return true;
}

/**
 * An early frame sealed under the handshake key on a marked nonce. The random part comes from
 * `encrypt` over nothing, so it uses the same PRNG as every other seal on this platform.
 */
function sealConfirmingEarlyFrame(sharedKey: SharedKey, data: string): string {
  const nonce = new Uint8Array(encrypt(sharedKey, new ArrayBuffer(0)), 0, NONCE_BYTES);
  nonce.set(CONFIRMING_EARLY_NONCE_MARK, 0);
  const box = secretboxSeal(UTF8_ENCODER.encode(data), nonce, sharedKey);
  const out = new Uint8Array(NONCE_BYTES + box.byteLength);
  out.set(nonce, 0);
  out.set(box, NONCE_BYTES);
  return arrayBufferToBase64(out.buffer);
}

interface E2EEHelloMessage {
  type: "e2ee_hello";
  key: string;
  capabilities?: E2EECapabilities;
  /** The client proves it holds the account key (account-key.ts). */
  account?: RelayAccountProof;
  /** Shared projects (v6.0.0): a member proves its own account key for one share. */
  guest?: RelayGuestProof;
  /** Item 79: a borrower's daemon proves a delegated device key for one loan. */
  loan?: RelayLoanProof;
}

/**
 * What a shared project's member puts in its `e2ee_hello` (plan §6.3, guest-proof.ts). It never
 * verifies as an owner `account` proof, nor one as it, never rides beside one, and needs session
 * keys.
 */
export type { ProvenGuest, RelayGuestProof, ProvenLoan, RelayLoanProof };

interface E2EEReadyMessage {
  type: "e2ee_ready";
  capabilities?: E2EECapabilities;
  /** With `sessionKeys`: the daemon's ephemeral public key for this connection. */
  key?: string;
}

interface E2EECapabilities {
  binaryCiphertext?: boolean;
  /** W7: sealed frames all binary, the plaintext's first byte names the kind. */
  framedPlaintext?: boolean;
  /**
   * W7: frames may arrive deflate-raw packed (daemon → client only); needs framedPlaintext.
   * Audit RELAY-8: a current client never asks, a current daemon never echoes it, and neither
   * side reads it: a 5.0.0 client's hello may still carry it.
   */
  deflate?: boolean;
  /** Round 36 S3: forward-secret session keys with per-frame counters; needs framedPlaintext. */
  sessionKeys?: boolean;
  /**
   * Audit RELAY-8: this side reads padded frames, so the peer pads what it sends; needs
   * framedPlaintext. In the hello a client asks; a daemon that reads them echoes it in the ready.
   */
  padded?: boolean;
  /**
   * Audit D16: the client sends a confirm frame first when the ready echoes this; needs session
   * keys. Clients without it (released 5.0.0 apps) keep today's behaviour.
   */
  confirm?: boolean;
  /**
   * Audit RELAY-6, in the ready: this daemon reads a timed relay proof (account-key.ts v2). A
   * client sends one only to a daemon that said so, since a 5.0.0 daemon refuses it.
   */
  timedProof?: boolean;
  /**
   * Audit RELAY-10: the client's confirm frame carries the transcript hash (`handshakeTranscript`)
   * of the hello and ready, and a daemon that echoes this refuses a bare one. A hello that asks
   * for it also asks for everything above but `deflate` and `timedProof`; a ready that echoes it
   * also echoes `sessionKeys`, `confirm`, `padded` and `timedProof`. Either one short is a
   * downgrade.
   */
  transcript?: boolean;
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isOptionalBoolean(value: unknown): boolean {
  return value === undefined || typeof value === "boolean";
}

function isE2EECapabilities(value: unknown): value is E2EECapabilities {
  return (
    value === undefined ||
    (isRecord(value) &&
      isOptionalBoolean(value.binaryCiphertext) &&
      isOptionalBoolean(value.framedPlaintext) &&
      isOptionalBoolean(value.deflate) &&
      isOptionalBoolean(value.sessionKeys) &&
      isOptionalBoolean(value.padded) &&
      isOptionalBoolean(value.confirm) &&
      isOptionalBoolean(value.timedProof) &&
      isOptionalBoolean(value.transcript))
  );
}

function isRelayAccountProof(value: unknown): value is RelayAccountProof | undefined {
  return (
    value === undefined ||
    (isRecord(value) &&
      typeof value.key === "string" &&
      typeof value.sig === "string" &&
      (value.at === undefined || typeof value.at === "number"))
  );
}

function isNonEmptyString(value: unknown, max: number): value is string {
  return typeof value === "string" && value.length > 0 && value.length <= max;
}

function isRelayGuestProof(value: unknown): value is RelayGuestProof | undefined {
  return (
    value === undefined ||
    (isRecord(value) &&
      typeof value.shareId === "string" &&
      PROJECT_SHARE_ID_PATTERN.test(value.shareId) &&
      isNonEmptyString(value.key, 128) &&
      isNonEmptyString(value.sig, 256) &&
      (value.at === undefined || typeof value.at === "number"))
  );
}

function isRelayLoanProof(value: unknown): value is RelayLoanProof | undefined {
  return value === undefined || isRelayLoanProofShape(value);
}

function isE2EEHelloMessage(value: unknown): value is E2EEHelloMessage {
  return (
    isRecord(value) &&
    value.type === "e2ee_hello" &&
    typeof value.key === "string" &&
    value.key.trim().length > 0 &&
    isE2EECapabilities(value.capabilities) &&
    isRelayAccountProof(value.account) &&
    isRelayGuestProof(value.guest) &&
    isRelayLoanProof(value.loan)
  );
}

function isE2EEReadyMessage(value: unknown): value is E2EEReadyMessage {
  return (
    isRecord(value) &&
    value.type === "e2ee_ready" &&
    isE2EECapabilities(value.capabilities) &&
    (value.key === undefined || typeof value.key === "string")
  );
}

function supportsBinaryCiphertext(message: E2EEHelloMessage | E2EEReadyMessage): boolean {
  return message.capabilities?.binaryCiphertext === true;
}

/**
 * Framing rides on binary ciphertext; padding (RELAY-8) rides on framing. Audit RELAY-8: `deflate`
 * is read by neither side, so a hello or ready that carries it (a 5.0.0 client's, or one a relay
 * wrote) agrees to nothing.
 */
function negotiatedFraming(message: E2EEHelloMessage | E2EEReadyMessage): {
  framedPlaintext: boolean;
  padded: boolean;
} {
  const framedPlaintext =
    supportsBinaryCiphertext(message) && message.capabilities?.framedPlaintext === true;
  return {
    framedPlaintext,
    padded: framedPlaintext && message.capabilities?.padded === true,
  };
}

function readyCapabilities(options: EncryptedChannelOptions): E2EECapabilities | undefined {
  if (!options.binaryCiphertext) return undefined;
  return {
    binaryCiphertext: true,
    ...(options.framedPlaintext ? { framedPlaintext: true } : {}),
    ...(options.framedPlaintext && options.session ? { sessionKeys: true } : {}),
    ...(options.framedPlaintext && options.padded ? { padded: true } : {}),
    ...(options.framedPlaintext && options.session?.confirm ? { confirm: true } : {}),
    // Audit RELAY-10: the confirm frame will carry the transcript hash, and a bare one is refused.
    ...(options.framedPlaintext && options.session?.transcript ? { transcript: true } : {}),
    // Audit RELAY-6: a daemon reads timed proofs; the client sends them from the next hello on.
    ...(options.framedPlaintext && options.session && options.daemonKeyPair
      ? { timedProof: true }
      : {}),
  };
}

function readyMessage(options: EncryptedChannelOptions): E2EEReadyMessage {
  const capabilities = readyCapabilities(options);
  return {
    type: "e2ee_ready",
    ...(capabilities ? { capabilities } : {}),
    ...(options.session && options.daemonEphemeralPublicKeyB64
      ? { key: options.daemonEphemeralPublicKeyB64 }
      : {}),
  };
}

function buildInvalidHelloError(rawText: string, parsed?: unknown): Error {
  const parsedRecord = isRecord(parsed) ? parsed : null;
  const rawType = parsedRecord?.type;
  function describeType(value: unknown): string {
    if (typeof value === "string") return value;
    if (value === undefined) return "undefined";
    return typeof value;
  }
  const receivedType = describeType(rawType);
  const hasKey = typeof parsedRecord?.key === "string" && parsedRecord.key.trim().length > 0;
  const compact = rawText.replace(/\s+/g, " ").trim();
  const preview = compact.length > 160 ? `${compact.slice(0, 157)}...` : compact;
  return new Error(
    `Invalid hello message (receivedType=${receivedType}, hasKey=${hasKey}, preview=${JSON.stringify(preview)})`,
  );
}

const HANDSHAKE_RETRY_MS = 1000;
const MAX_PENDING_SENDS = 200;
const PENDING_SENDS_FULL_CLOSE_REASON = "Send queue full before the encrypted link was ready";
/**
 * The channel's own failure close (a bad frame, a backlog that failed or overflowed). An
 * app-defined code, not 1011: the browser's WebSocket.close throws for any code but 1000 and
 * 3000–4999, so on the web app and Electron a 1011 never closed the link and nothing reconnected.
 */
const CHANNEL_FAILURE_CLOSE_CODE = 4011;
const REHANDSHAKE_REJECTION_CODE = 1008;
const ENCRYPTED_PAYLOAD_OVERHEAD_BYTES = 40;

export function base64EncryptedWireByteLength(plaintextBytes: number): number {
  return 4 * Math.ceil((plaintextBytes + ENCRYPTED_PAYLOAD_OVERHEAD_BYTES) / 3);
}

export function maxBase64EncryptedPlaintextByteLength(wireBytes: number): number {
  return Math.floor(wireBytes / 4) * 3 - ENCRYPTED_PAYLOAD_OVERHEAD_BYTES;
}
const REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON = "E2EE re-handshake key mismatch";

interface TimeoutWithUnref {
  unref(): void;
}

function hasUnref(timeout: unknown): timeout is TimeoutWithUnref {
  return (
    typeof timeout === "object" &&
    timeout !== null &&
    "unref" in timeout &&
    typeof (timeout as Record<string, unknown>).unref === "function"
  );
}

/**
 * Audit RELAY-6: daemons (static key, base64) whose ready said they read timed proofs, in this
 * process. A hello to one of them carries the time inside its signature; to any other (a 5.0.0
 * daemon, or one not reached yet) the untimed proof, which every daemon reads.
 */
const timedProofDaemons = new Set<string>();

/** Whether a hello to this daemon carries the timed proof (RELAY-6). */
export function daemonTakesTimedProofs(daemonPublicKeyB64: string): boolean {
  return timedProofDaemons.has(daemonPublicKeyB64);
}

/** Records a daemon known to read timed proofs, from a ready or a caller's own memory of one. */
export function rememberTimedProofDaemon(daemonPublicKeyB64: string): void {
  timedProofDaemons.add(daemonPublicKeyB64);
}

/**
 * Audit RELAY-10: the capabilities a client pins per daemon (its static key) once a ready that
 * named them was followed by a session frame the daemon sealed. A later ready from that daemon
 * without one of them is refused (`ChannelDowngradeError`): a relay deleted it, or the daemon was
 * rolled back to an older release.
 */
export const DAEMON_CHANNEL_LEVEL = [
  "sessionKeys",
  "confirm",
  "padded",
  "transcript",
  "timedProof",
] as const;
export type DaemonChannelCapability = (typeof DAEMON_CHANNEL_LEVEL)[number];

/** Audit RELAY-10: each daemon's pinned level in this process (static key, base64). */
const daemonChannelLevels = new Map<string, Set<DaemonChannelCapability>>();
const daemonChannelLevelListeners = new Set<
  (daemonPublicKeyB64: string, level: DaemonChannelCapability[]) => void
>();

function isDaemonChannelCapability(value: unknown): value is DaemonChannelCapability {
  return (DAEMON_CHANNEL_LEVEL as readonly unknown[]).includes(value);
}

/** Audit RELAY-10: what this process has pinned for a daemon, in `DAEMON_CHANNEL_LEVEL` order. */
export function daemonChannelLevel(daemonPublicKeyB64: string): DaemonChannelCapability[] {
  const level = daemonChannelLevels.get(daemonPublicKeyB64);
  return level ? DAEMON_CHANNEL_LEVEL.filter((capability) => level.has(capability)) : [];
}

/**
 * Audit RELAY-10: adds to a daemon's pinned level (it only grows). A caller's stored memory goes
 * in here at startup; names this build does not know are dropped. Listeners are told only of
 * what a handshake pinned, so a store loading itself writes nothing back. True when it grew.
 */
export function rememberDaemonChannelLevel(
  daemonPublicKeyB64: string,
  level: readonly unknown[],
): boolean {
  return pinDaemonChannelLevel(daemonPublicKeyB64, level, false);
}

function pinDaemonChannelLevel(
  daemonPublicKeyB64: string,
  level: readonly unknown[],
  notify: boolean,
): boolean {
  const known = level.filter(isDaemonChannelCapability);
  if (!daemonPublicKeyB64 || known.length === 0) return false;
  let pinned = daemonChannelLevels.get(daemonPublicKeyB64);
  if (!pinned) {
    pinned = new Set();
    daemonChannelLevels.set(daemonPublicKeyB64, pinned);
  }
  const before = pinned.size;
  for (const capability of known) pinned.add(capability);
  if (pinned.has("timedProof")) timedProofDaemons.add(daemonPublicKeyB64);
  if (pinned.size === before) return false;
  if (notify) {
    const snapshot = daemonChannelLevel(daemonPublicKeyB64);
    for (const listener of daemonChannelLevelListeners) {
      try {
        listener(daemonPublicKeyB64, snapshot);
      } catch {
        // A store that fails costs only the durability: the memory still holds the pin.
      }
    }
  }
  return true;
}

/**
 * Audit RELAY-10: told whenever a handshake raises a daemon's pinned level (to store it), and with
 * an empty level when `forgetDaemonChannelLevel` drops it.
 */
export function onDaemonChannelLevel(
  listener: (daemonPublicKeyB64: string, level: DaemonChannelCapability[]) => void,
): () => void {
  daemonChannelLevelListeners.add(listener);
  return () => daemonChannelLevelListeners.delete(listener);
}

/**
 * Audit RELAY-10: drops a daemon's pin (and the timed proof it carried), for a person who
 * knowingly went back to an older server, and for tests. Nothing else lowers a pin. Listeners are
 * told with an empty level, so a store drops it too.
 */
export function forgetDaemonChannelLevel(daemonPublicKeyB64: string): void {
  if (!daemonChannelLevels.delete(daemonPublicKeyB64)) return;
  timedProofDaemons.delete(daemonPublicKeyB64);
  for (const listener of daemonChannelLevelListeners) {
    try {
      listener(daemonPublicKeyB64, []);
    } catch {
      // A store that fails keeps the old pin on disk; the memory has already let it go.
    }
  }
}

/** Audit RELAY-10: the message every refused downgrade starts with. */
export const E2EE_DOWNGRADE_REFUSED = "E2EE downgrade refused";

/**
 * Audit RELAY-10: a client refused a ready that offers less than it must: less than this daemon
 * offered before (`missing`, an older server or a relay rewriting the handshake), or no session
 * keys for a channel that would carry a proof or a credential.
 */
export class ChannelDowngradeError extends Error {
  readonly missing: DaemonChannelCapability[];
  constructor(detail: string, missing: DaemonChannelCapability[] = []) {
    super(`${E2EE_DOWNGRADE_REFUSED}: ${detail}`);
    this.name = "ChannelDowngradeError";
    this.missing = missing;
  }
}

/** Whether a close reason or error message is a refused downgrade (RELAY-10). */
export function isChannelDowngrade(reasonOrMessage: string): boolean {
  return reasonOrMessage.startsWith(E2EE_DOWNGRADE_REFUSED);
}

const TRANSCRIPT_HMAC_KEY = utf8("shuriken/e2ee-transcript/v1");
/** Audit RELAY-10: how many bytes of the transcript HMAC a confirm frame carries. */
export const HANDSHAKE_TRANSCRIPT_BYTES = 32;

/**
 * Audit RELAY-10: T = the first 32 bytes of HMAC-SHA-512(key utf8("shuriken/e2ee-transcript/v1"),
 * u32be(len hello) ‖ hello ‖ u32be(len ready) ‖ ready), over the UTF-8 of the exact wire text:
 * the client's hello as sent and ready as read, the daemon's hello as read and ready as sent. It
 * rides in the client's confirm frame, sealed under the session key, so a relay that changed
 * either text cannot make the daemon's T match.
 */
export function handshakeTranscript(helloText: string, readyText: string): Uint8Array {
  const hello = UTF8_ENCODER.encode(helloText);
  const ready = UTF8_ENCODER.encode(readyText);
  const message = new Uint8Array(8 + hello.byteLength + ready.byteLength);
  const view = new DataView(message.buffer);
  view.setUint32(0, hello.byteLength);
  message.set(hello, 4);
  view.setUint32(4 + hello.byteLength, ready.byteLength);
  message.set(ready, 8 + hello.byteLength);
  return hmacSha512(TRANSCRIPT_HMAC_KEY, message).slice(0, HANDSHAKE_TRANSCRIPT_BYTES);
}

/** What a ready that echoes `transcript` (a RELAY-10 daemon answering a RELAY-10 client) also says. */
const IMPLIED_BY_TRANSCRIPT: readonly DaemonChannelCapability[] = [
  "sessionKeys",
  "confirm",
  "padded",
  "timedProof",
];

/**
 * Client side, audit RELAY-10: throws `ChannelDowngradeError` when a ready offers less than this
 * daemon's pin, or echoes `transcript` without what every daemon that echoes it also says.
 */
function checkReadyLevel(ready: E2EEReadyMessage, daemonPublicKeyB64: string | undefined): void {
  const capabilities = ready.capabilities ?? {};
  const offered = (capability: DaemonChannelCapability) => capabilities[capability] === true;
  const pinned = daemonPublicKeyB64 ? daemonChannelLevel(daemonPublicKeyB64) : [];
  const missing = pinned.filter((capability) => !offered(capability));
  if (missing.length > 0) {
    throw new ChannelDowngradeError(
      `this server's handshake left out ${missing.join(", ")}, which it offered before ` +
        "(the server is older than it was, or the relay rewrote the handshake)",
      missing,
    );
  }
  if (offered("transcript")) {
    const short = IMPLIED_BY_TRANSCRIPT.filter((capability) => !offered(capability));
    if (short.length > 0) {
      throw new ChannelDowngradeError(
        `the ready names transcript without ${short.join(", ")}`,
        short,
      );
    }
  }
}

/** Client side, audit RELAY-10: the level a ready names, pinned once the daemon's frame opens. */
function readyLevel(ready: E2EEReadyMessage): DaemonChannelCapability[] {
  const capabilities = ready.capabilities ?? {};
  return DAEMON_CHANNEL_LEVEL.filter((capability) => capabilities[capability] === true);
}

/** The one proof a client's hello carries, if any: account, guest or loan, never two. */
async function signClientProofs(
  security: ClientChannelSecurity,
  daemonPublicKey: Uint8Array,
  clientPublicKey: Uint8Array,
  at?: number,
): Promise<Pick<E2EEHelloMessage, "account" | "guest" | "loan">> {
  if (security.loan && (security.account || security.guest)) {
    throw new Error("One proof per handshake");
  }
  if (security.account && security.guest) {
    throw new Error("Account and guest proofs together refused");
  }
  if (security.account) {
    return {
      account: await signRelayClient(security.account, daemonPublicKey, clientPublicKey, { at }),
    };
  }
  if (security.guest) {
    const { signer, shareId } = security.guest;
    return {
      guest: await signRelayGuest(signer, shareId, daemonPublicKey, clientPublicKey, { at }),
    };
  }
  if (security.loan) {
    return {
      loan: await signRelayLoan({
        ...security.loan,
        daemonPublicKey,
        clientEphemeralPublicKey: clientPublicKey,
      }),
    };
  }
  return {};
}

/**
 * Creates an encrypted channel as the initiator (client).
 *
 * The client:
 * 1. Receives daemon's public key via QR code
 * 2. Generates own keypair
 * 3. Sends e2ee_hello with own public key
 * 4. Derives shared key and starts encrypted communication
 */
export async function createClientChannel(
  transport: Transport,
  daemonPublicKeyB64: string,
  events: EncryptedChannelEvents = {},
  codec: EncryptedChannelCodec = {},
  security: ClientChannelSecurity = {},
): Promise<EncryptedChannel> {
  const keyPair = generateKeyPair();
  const daemonPublicKey = importPublicKey(daemonPublicKeyB64);
  const sharedKey = deriveSharedKey(keyPair.secretKey, daemonPublicKey);
  const proofs = await signClientProofs(
    security,
    daemonPublicKey,
    keyPair.publicKey,
    timedProofDaemons.has(daemonPublicKeyB64) ? Date.now() : undefined,
  );
  // A loan channel carries only binary loan frames: nothing to pipeline, nothing packed.
  const loan = proofs.loan !== undefined;

  // Send e2ee_hello with our public key. W7: ask for framed binary frames always. Round 36 S3:
  // and for session keys. Audit RELAY-8: and for padded frames, never for packed ones (an older
  // daemon packs only when asked). Audit RELAY-10: and for the transcript in the confirm frame.
  const ourPublicKeyB64 = exportPublicKey(keyPair.publicKey);
  const hello: E2EEHelloMessage = {
    type: "e2ee_hello",
    key: ourPublicKeyB64,
    capabilities: {
      binaryCiphertext: true,
      framedPlaintext: true,
      sessionKeys: true,
      confirm: true,
      padded: true,
      transcript: true,
    },
    ...proofs,
  };
  const helloText = JSON.stringify(hello);

  const channel = new EncryptedChannel(transport, sharedKey, events, {
    pipelineText: !loan,
    ...(codec.mayPipeline && !loan ? { mayPipeline: codec.mayPipeline } : {}),
    ...(loan ? { loan: true } : {}),
    timedProofDaemonKeyB64: daemonPublicKeyB64,
    helloText,
    ...(proofs.account || proofs.guest || proofs.loan ? { provedPeer: true } : {}),
    credentialsNeedSessionKeys: true,
    session: {
      pending: { ephemeral: keyPair, daemonStaticPublicKey: daemonPublicKey },
      sendCounter: 0,
      receiveCounter: 0,
    },
  });

  let retry: ReturnType<typeof setInterval> | null = null;
  const emitSendError = (error: unknown) => {
    const err = error instanceof Error ? error : new Error(String(error));
    events.onerror?.(err);
  };
  const sendHello = () => {
    try {
      const result = transport.send(helloText);
      if (result) {
        void result.catch(emitSendError);
      }
      return true;
    } catch (error) {
      // This can happen during daemon restarts while the socket transitions
      // through CLOSING/CLOSED states. Report it but do not throw from timers.
      emitSendError(error);
      return false;
    }
  };
  const clearRetry = () => {
    if (retry) {
      clearInterval(retry);
      retry = null;
    }
  };

  channel.onTransitionToOpen(() => clearRetry());
  channel.onClose(() => clearRetry());

  sendHello();
  retry = setInterval(() => {
    if (channel.isOpen()) {
      clearRetry();
      return;
    }
    sendHello();
  }, HANDSHAKE_RETRY_MS);
  // Avoid keeping Node processes alive (e.g. tests) if the handshake is stuck.
  if (hasUnref(retry)) {
    retry.unref();
  }

  return channel;
}

/**
 * Who the hello proves it is: the account (the owner's devices) or the member of one share, never
 * both. A proof that is present must verify; a guest proof needs a listener that admits guests.
 */
function provenPeer(
  msg: E2EEHelloMessage,
  daemonPublicKey: Uint8Array,
  clientPublicKey: Uint8Array,
  security: DaemonChannelSecurity,
): { accountKey: string | null; guest: ProvenGuest | null; loan: ProvenLoan | null } {
  // A member is never also the owner: one handshake carries one kind of proof.
  if (msg.loan && (msg.account || msg.guest)) throw new Error("One proof per handshake");
  if (msg.account && msg.guest) throw new Error("Account and guest proofs together refused");
  // Audit RELAY-6: a timed proof verifies only inside its window of this daemon's clock.
  const now = security.now?.() ?? Date.now();
  const accountKey = verifyRelayClient(msg.account, daemonPublicKey, clientPublicKey, { now });
  if (msg.account && !accountKey) throw new Error("Invalid account proof");
  if (msg.guest && security.guests !== true) {
    throw new Error("Guest proof refused on this listener");
  }
  const guest = verifyRelayGuest(msg.guest, daemonPublicKey, clientPublicKey, { now });
  if (msg.guest && !guest) throw new Error("Invalid guest proof");
  if (msg.loan && security.loans !== true) {
    throw new Error("Loan proof refused on this listener");
  }
  const loan = verifyRelayLoan(msg.loan, daemonPublicKey, clientPublicKey);
  if (msg.loan && !loan) throw new Error("Invalid loan proof");
  return { accountKey, guest, loan };
}

/**
 * The daemon's half of a hello: the keys, what was agreed, and the account (or the member of a
 * share) the client proved.
 */
function acceptHello(
  msg: E2EEHelloMessage,
  daemonKeyPair: KeyPair,
  security: DaemonChannelSecurity,
): {
  sharedKey: SharedKey;
  channelOptions: EncryptedChannelOptions;
  accountKey: string | null;
  guest: ProvenGuest | null;
  loan: ProvenLoan | null;
} {
  const clientPublicKey = importPublicKey(msg.key);
  const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey);
  // Audit RELAY-6: the proofs first, so a hello whose proof fails never takes a place in the
  // memory below.
  const { accountKey, guest, loan } = provenPeer(
    msg,
    daemonKeyPair.publicKey,
    clientPublicKey,
    security,
  );
  // A recorded handshake played again (by the relay, or anyone on the path) would replay
  // its early frames into a new session: every client key is fresh, so a repeat is refused.
  const seen = security.seenClientKeys ?? processSeenClientKeys;
  if (!seen.remember(handshakeReplayKey(sharedKey), Boolean(accountKey || guest || loan))) {
    throw new Error("Replayed E2EE handshake");
  }

  const binaryCiphertext = supportsBinaryCiphertext(msg);
  const framing = negotiatedFraming(msg);
  const sessionKeys = framing.framedPlaintext && msg.capabilities?.sessionKeys === true;
  // Every client that proves the account (or a share) also asks for session keys, so a proof
  // without them is a relay that stripped the request to force the weaker channel.
  if ((accountKey || guest || loan) && !sessionKeys) throw new Error(E2EE_DOWNGRADE_REFUSED);
  const transcript = helloAsksTranscript(msg, sessionKeys, framing.padded);
  const daemonEphemeral = sessionKeys ? generateKeyPair() : null;
  const session: SessionKeyState | undefined = daemonEphemeral
    ? {
        ...deriveSessionKeys({
          role: "daemon",
          ourEphemeral: daemonEphemeral,
          daemonStatic: daemonKeyPair,
          peerEphemeralPublicKey: clientPublicKey,
          clientEphemeralPublicKey: clientPublicKey,
          daemonEphemeralPublicKey: daemonEphemeral.publicKey,
        }),
        sendCounter: 0,
        receiveCounter: 0,
        earlyNonces: new Set(),
        sawSessionFrame: false,
        heldEarly: [],
        ...(msg.capabilities?.confirm === true ? { confirm: true } : {}),
        ...(transcript ? { transcript: true } : {}),
      }
    : undefined;
  daemonEphemeral?.secretKey.fill(0);
  const channelOptions: EncryptedChannelOptions = {
    daemonKeyPair,
    binaryCiphertext,
    framedPlaintext: framing.framedPlaintext,
    ...(loan ? { loan: true } : {}),
    // Audit RELAY-8: pad what goes to a client that reads padding.
    padded: framing.padded,
    ...(session && daemonEphemeral
      ? {
          session,
          daemonEphemeralPublicKeyB64: exportPublicKey(daemonEphemeral.publicKey),
        }
      : {}),
  };
  return { sharedKey, channelOptions, accountKey, guest, loan };
}

/**
 * Daemon side, audit RELAY-10: whether the hello asks for the transcript. A client that asks for
 * it asks for session keys, padding and the confirm frame too, so a hello that kept `transcript`
 * and lost one of them was cut down on the way: refused.
 */
function helloAsksTranscript(
  msg: E2EEHelloMessage,
  sessionKeys: boolean,
  padded: boolean,
): boolean {
  if (msg.capabilities?.transcript !== true) return false;
  if (!(sessionKeys && padded && msg.capabilities.confirm === true)) {
    throw new Error(`${E2EE_DOWNGRADE_REFUSED}: the hello names transcript without the rest`);
  }
  return true;
}

/**
 * Creates an encrypted channel as the responder (daemon).
 *
 * The daemon:
 * 1. Has pre-generated keypair (public key was in QR)
 * 2. Waits for client's e2ee_hello with their public key
 * 3. Derives shared key and starts encrypted communication
 */
export async function createDaemonChannel(
  transport: Transport,
  daemonKeyPair: KeyPair,
  events: EncryptedChannelEvents = {},
  // Audit RELAY-8: a daemon packs nothing, so its codec carries nothing it uses; the slot stays
  // so callers keep their argument order.
  _codec: EncryptedChannelCodec = {},
  security: DaemonChannelSecurity = {},
): Promise<EncryptedChannel> {
  return new Promise((resolve, reject) => {
    const bufferedMessages: TransportMessage[] = [];
    const shouldIgnorePostHelloPlaintext = (message: TransportMessage): boolean => {
      try {
        if (message.isBinary) return false;
        const text = decodeTransportText(message.data);
        const parsed: unknown = JSON.parse(text);
        return isE2EEHelloMessage(parsed) || isE2EEReadyMessage(parsed);
      } catch {
        return false;
      }
    };

    const handleHello = async (message: TransportMessage): Promise<void> => {
      try {
        if (message.isBinary) {
          throw buildInvalidHelloError("<binary frame>");
        }
        const helloText = decodeTransportText(message.data);

        let parsed: unknown;
        try {
          parsed = JSON.parse(helloText);
        } catch {
          throw buildInvalidHelloError(helloText);
        }

        if (!isE2EEHelloMessage(parsed)) {
          throw buildInvalidHelloError(helloText, parsed);
        }

        const msg = parsed;

        // Buffer any subsequent messages that arrive while we're doing async
        // WebCrypto work to derive the shared key. Without this, it's possible
        // for the next message (already encrypted) to be misinterpreted as a
        // second hello, causing the handshake to fail.
        const bufferNext = (next: TransportMessage): void => {
          bufferedMessages.push(next);
        };
        Object.assign(transport, { onmessage: bufferNext });

        const { sharedKey, channelOptions, accountKey, guest, loan } = acceptHello(
          msg,
          daemonKeyPair,
          security,
        );
        // Audit RELAY-10: the ready is sent (and resent) as this one text, and the transcript
        // binds the hello as read to it.
        const readyText = JSON.stringify(readyMessage(channelOptions));
        channelOptions.readyText = readyText;
        if (channelOptions.session?.confirm) {
          channelOptions.session.expectedTranscript = handshakeTranscript(helloText, readyText);
        }
        await transport.send(readyText);

        const channel = new EncryptedChannel(transport, sharedKey, events, channelOptions);
        channel.peerAccountKey = accountKey;
        channel.peerGuest = guest;
        channel.peerLoan = loan;
        channel.setState("open");
        events.onopen?.();

        for (const buffered of bufferedMessages) {
          if (shouldIgnorePostHelloPlaintext(buffered)) continue;
          transport.onmessage?.(buffered);
        }

        resolve(channel);
      } catch (error) {
        reject(error);
      }
    };

    Object.assign(transport, {
      onmessage: handleHello,
      onerror: (error: Error) => {
        reject(error);
      },
      onclose: (code: number, reason: string) => {
        reject(new Error(`Connection closed during handshake: ${code} ${reason}`));
      },
    });
  });
}

/**
 * Audit RELAY-8: a padded frame as the unpadded kind byte and body (a view, no copy); any other
 * frame as it is. A body length past the frame, or padding that is not zero, fails the channel.
 */
function unpadFrame(frame: Uint8Array): Uint8Array {
  if (frame.byteLength === 0 || (frame[0]! & PADDED_FRAME_FLAG) === 0) return frame;
  if (frame.byteLength < PADDED_FRAME_HEADER_BYTES) throw new Error("Truncated padded frame");
  const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
  const end = PADDED_FRAME_HEADER_BYTES + view.getUint32(1);
  if (end > frame.byteLength) throw new Error("Padded frame body past its end");
  for (let i = end; i < frame.byteLength; i += 1) {
    if (frame[i] !== 0) throw new Error("Padded frame with non-zero padding");
  }
  // The kind byte goes in the byte before the body, so the result is one contiguous view.
  const out = frame.subarray(PADDED_FRAME_HEADER_BYTES - 1, end);
  out[0] = frame[0]! & ~PADDED_FRAME_FLAG;
  return out;
}

/**
 * Encrypted channel that wraps a transport with E2EE.
 */
export class EncryptedChannel {
  private transport: Transport;
  private sharedKey: SharedKey;
  private state: ChannelState = "handshaking";
  private events: EncryptedChannelEvents;
  private options: EncryptedChannelOptions;
  private pendingSends: Array<string | ArrayBuffer> = [];
  /**
   * Client side, audits RELAY-6 and RELAY-10: the level the ready named (`timedProof` among it),
   * pinned for the daemon once a session frame it sealed opens; null once pinned or when none.
   */
  private levelPending: DaemonChannelCapability[] | null = null;
  /** Client side, audit RELAY-10: the transcript hash the confirm frames carry. */
  private transcript: Uint8Array | null = null;
  /**
   * Client side, audit RELAY-10: the ready echoed `transcript`, so the daemon's first session
   * frame must be its own confirm, `[0x04] ‖ T`. Only that frame proves the daemon checked T, so
   * only it pins the level: any older daemon seals frames too, after a ready a relay could have
   * raised.
   */
  private awaitingDaemonConfirm = false;
  /**
   * Daemon side, audit RELAY-10: what the daemon sends before a transcript client's confirm has
   * checked out. It goes after the daemon's own confirm, which is its session frame 0.
   */
  private heldForConfirm: Array<{
    data: string | ArrayBuffer;
    sent: (value: void | PromiseLike<void>) => void;
    failed: (error: unknown) => void;
  }> = [];
  private onOpenCallbacks: Array<() => void> = [];
  private onCloseCallbacks: Array<() => void> = [];
  /**
   * Daemon side: the account key the client proved it holds in this handshake (base64), or null
   * when it proved none. Who may use the daemon is decided from this, never from the relay.
   */
  peerAccountKey: string | null = null;
  /**
   * Daemon side, shared projects (v6.0.0): the member account key and share the client proved
   * (guest-proof.ts), or null. It says who asks, never what they may do: that needs a live,
   * owner-signed grant for the share naming this key (project-grant.ts). Never set beside
   * `peerAccountKey`.
   */
  peerGuest: ProvenGuest | null = null;
  /**
   * Daemon side, item 79: the loan and delegated device key a borrower's daemon proved
   * (loan-proof.ts), or null. Admission needs `admitLoan` against a verified grant as well. Never
   * set beside `peerAccountKey` or `peerGuest`; when set, the channel carries loan frames only.
   */
  peerLoan: ProvenLoan | null = null;

  constructor(
    transport: Transport,
    sharedKey: SharedKey,
    events: EncryptedChannelEvents = {},
    options: EncryptedChannelOptions = {},
  ) {
    this.transport = transport;
    this.sharedKey = sharedKey;
    this.events = events;
    this.options = options;

    Object.assign(transport, {
      onmessage: (message: TransportMessage) => this.handleMessage(message),
      onclose: (code: number, reason: string) => {
        this.state = "closed";
        // Audit D16: early frames never confirmed by a live client go with the channel.
        if (this.options.session) this.options.session.heldEarly = undefined;
        this.dropHeldForConfirm();
        this.events.onclose?.(code, reason);
        for (const cb of this.onCloseCallbacks) cb();
      },
      onerror: (error: Error) => {
        this.events.onerror?.(error);
      },
    });
  }

  setState(state: ChannelState): void {
    this.state = state;
  }

  private async handleMessage(message: TransportMessage): Promise<void> {
    if (this.state === "handshaking") {
      try {
        if (message.isBinary) return;
        const text = decodeTransportText(message.data);
        const parsed: unknown = JSON.parse(text);
        if (isE2EEReadyMessage(parsed)) {
          // Audit RELAY-10: a ready short of this daemon's pinned level is refused before
          // anything goes under it.
          try {
            checkReadyLevel(parsed, this.options.timedProofDaemonKeyB64);
          } catch (error) {
            this.refuseDowngrade(error);
            return;
          }
          // Audit RELAY-10: the hello as sent and this ready as read.
          this.transcript =
            this.options.helloText !== undefined
              ? handshakeTranscript(this.options.helloText, text)
              : null;
          // Audit RELAY-6 and RELAY-10: the level this ready names (`timedProof` among it). The
          // ready is plaintext anyone on the path can write, and a frame any daemon seals proves
          // nothing about it (a relay can raise an older daemon's ready and still get its
          // frames), so it is pinned only once the daemon's own confirm frame, which carries T,
          // opens (receiveSessionFrame). A ready without `transcript` pins nothing.
          this.awaitingDaemonConfirm =
            this.transcript !== null &&
            parsed.capabilities?.transcript === true &&
            parsed.capabilities.sessionKeys === true;
          this.levelPending =
            this.awaitingDaemonConfirm && this.options.timedProofDaemonKeyB64 !== undefined
              ? readyLevel(parsed)
              : null;
          this.options.binaryCiphertext = supportsBinaryCiphertext(parsed);
          const framing = negotiatedFraming(parsed);
          this.options.framedPlaintext = framing.framedPlaintext;
          // Audit RELAY-8: pad only for a daemon that reads padding (an older one does not). An
          // echoed `deflate` is ignored, and a packed frame fails the channel.
          this.options.padded = framing.padded;
          try {
            this.finishClientSession(parsed, framing.framedPlaintext);
          } catch (error) {
            this.failHandshake(error);
            return;
          }
          this.state = "open";
          // Taken before anything the open callbacks send, so it is session frame 0.
          const confirmed = this.sendConfirm(parsed);
          confirmed.catch(() => undefined);
          this.events.onopen?.();
          for (const cb of this.onOpenCallbacks) cb();
          try {
            await confirmed;
            await this.flushPendingSends();
          } catch (error) {
            const err = error instanceof Error ? error : new Error(String(error));
            this.events.onerror?.(err);
            this.state = "closed";
            this.transport.close(CHANNEL_FAILURE_CLOSE_CODE, err.message);
          }
        }
      } catch {
        // ignore non-ready handshake traffic
      }
      return;
    }

    if (this.state !== "open") return;

    if (this.options.session) {
      this.receiveSessionFrame(message);
      return;
    }

    await this.receiveOpen(message);
  }

  private async receiveOpen(message: TransportMessage): Promise<void> {
    if (this.state !== "open") return;
    try {
      if (this.options.framedPlaintext && message.isBinary) {
        const plaintext = this.unframe(
          new Uint8Array(decrypt(this.sharedKey, requireArrayBuffer(message.data))),
        );
        this.events.onmessage?.(plaintext);
        return;
      }
      const ciphertext = await (async () => {
        // Handle (or ignore) any stray plaintext handshake traffic.
        try {
          if (message.isBinary) throw new Error("not plaintext handshake traffic");
          const text = decodeTransportText(message.data);
          if (text.trim().startsWith("{")) {
            const parsed: unknown = JSON.parse(text);

            if (isE2EEHelloMessage(parsed)) {
              if (this.options.daemonKeyPair) {
                await this.handleDaemonRehello(parsed);
              }
              return null;
            }

            if (isE2EEReadyMessage(parsed)) {
              return null;
            }

            // Any other JSON-looking payload is plaintext app traffic, which
            // means the peer is not encrypting (or we are out of sync).
            throw new Error("Received plaintext frame on encrypted channel");
          }
        } catch (error) {
          // If we detected plaintext protocol mismatch, fail hard.
          if (error instanceof Error && error.message.includes("plaintext frame")) {
            throw error;
          }
          // Otherwise ignore JSON parse/TextDecoder failures and fall back to
          // decoding ciphertext below.
        }

        if (this.options.binaryCiphertext) {
          return message.isBinary
            ? { data: requireArrayBuffer(message.data), isBinary: true as const }
            : {
                data: base64ToArrayBuffer(decodeTransportText(message.data)),
                isBinary: false as const,
              };
        }

        // COMPAT(binaryCiphertext): added in v0.2.3, remove legacy base64-only
        // receive mode after 2027-01-27.
        if (!message.isBinary) {
          return { data: base64ToArrayBuffer(decodeTransportText(message.data)), isBinary: null };
        }

        // Older transport adapters could lose the opcode. Retain the former
        // base64-first behavior only in the legacy path.
        try {
          return { data: base64ToArrayBuffer(decodeTransportText(message.data)), isBinary: null };
        } catch {
          return { data: requireArrayBuffer(message.data), isBinary: null };
        }
      })();

      if (ciphertext) {
        // Audit D16: only a client that asks for session keys marks its early frames, so one on a
        // daemon channel without them is a hello the relay stripped to deliver it unconfirmed.
        if (
          this.options.daemonKeyPair &&
          ciphertext.isBinary !== true &&
          isConfirmingEarlyNonce(new Uint8Array(ciphertext.data))
        ) {
          throw new Error("Confirming early frame without session keys");
        }
        const plaintextBytes = decrypt(this.sharedKey, ciphertext.data);
        const plaintext = decodePlaintext(plaintextBytes, ciphertext.isBinary);
        this.events.onmessage?.(plaintext);
      }
    } catch (error) {
      const err = error instanceof Error ? error : new Error(String(error));

      // Treat decryption/protocol errors as fatal so the peer can reconnect and
      // re-handshake. Emitting an error event here can cause higher-level code
      // to tear down the session without triggering a clean reconnect.
      try {
        this.transport.close(failureCloseCode(err), err.message);
      } catch {
        // ignore
      }
    }
  }

  async send(data: string | ArrayBuffer): Promise<void> {
    if (this.options.loan && typeof data === "string") throw new Error(LOAN_FRAMES_ONLY);
    if (this.state === "handshaking") {
      if (
        this.options.pipelineText &&
        typeof data === "string" &&
        this.pendingSends.length === 0 &&
        this.mayPipeline(data)
      ) {
        // Audit D16: marked, so a daemon holds it until this client proves it is live.
        await this.transport.send(sealConfirmingEarlyFrame(this.sharedKey, data));
        return;
      }
      if (this.pendingSends.length >= MAX_PENDING_SENDS) {
        // A full backlog never drops a frame (an RPC or a prompt would vanish with no reconnect):
        // close like a protocol error, so the client reconnects and its callers send again.
        this.state = "closed";
        this.pendingSends = [];
        this.transport.close(CHANNEL_FAILURE_CLOSE_CODE, PENDING_SENDS_FULL_CLOSE_REASON);
        throw new Error(PENDING_SENDS_FULL_CLOSE_REASON);
      }
      this.pendingSends.push(data);
      return;
    }

    if (this.state !== "open") {
      throw new Error("Channel not open");
    }

    const session = this.options.session;
    this.refuseUnsealedCredential(data, session);
    if (this.options.daemonKeyPair && session?.transcript && !session.confirmed) {
      return this.holdForConfirm(data);
    }
    if (session?.sendKey) {
      // The counter is taken and the frame handed to the transport in the same turn, so frames
      // leave in counter order however sends interleave.
      const counter = session.sendCounter;
      session.sendCounter += 1;
      await this.transport.send(encryptWithCounter(session.sendKey, counter, this.frame(data)));
      return;
    }

    if (this.options.framedPlaintext) {
      await this.transport.send(encrypt(this.sharedKey, this.frame(data)));
      return;
    }
    const ciphertext = encrypt(this.sharedKey, data);
    if (this.options.binaryCiphertext && data instanceof ArrayBuffer) {
      await this.transport.send(ciphertext);
      return;
    }
    // COMPAT(binaryCiphertext): added in v0.2.3, remove base64 binary sends
    // after 2027-01-27 once the supported peer floor includes negotiation.
    await this.transport.send(arrayBufferToBase64(ciphertext));
  }

  /** Audit RELAY-10 (D): a credential is never sealed under the handshake key, which a
   * later-stolen daemon key opens. Throws (and fails the channel). */
  private refuseUnsealedCredential(
    data: string | ArrayBuffer,
    session: SessionKeyState | undefined,
  ): void {
    if (session?.sendKey || !this.options.credentialsNeedSessionKeys) return;
    if (typeof data !== "string" || !carriesSessionCredential(data)) return;
    const error = new ChannelDowngradeError("a credential needs session keys");
    this.refuseDowngrade(error);
    throw error;
  }

  /**
   * Daemon side, audit RELAY-10: the daemon's confirm is its session frame 0, so nothing goes
   * before a transcript client's confirm checked out. Settles when the frame is handed on, so a
   * caller waiting on the socket still waits.
   */
  private holdForConfirm(data: string | ArrayBuffer): Promise<void> {
    if (this.heldForConfirm.length >= MAX_PENDING_SENDS) {
      this.failChannel(new Error(PENDING_SENDS_FULL_CLOSE_REASON));
      return Promise.reject(new Error(PENDING_SENDS_FULL_CLOSE_REASON));
    }
    return new Promise<void>((sent, failed) => {
      this.heldForConfirm.push({ data, sent, failed });
    });
  }

  /** Audit RELAY-2: a credential never rides early, and the caller's rule may hold back more. */
  private mayPipeline(text: string): boolean {
    if (carriesSessionCredential(text)) return false;
    return this.options.mayPipeline ? this.options.mayPipeline(text) : true;
  }

  /**
   * Kind byte, then the bytes, padded to the bucket when the peer reads padding. Audit RELAY-8:
   * never packed, so a frame's length never follows how well its content compresses.
   */
  private frame(data: string | ArrayBuffer): ArrayBuffer {
    const text = typeof data === "string";
    const bytes = text ? UTF8_ENCODER.encode(data) : new Uint8Array(data);
    if (this.options.loan && text) throw new Error(LOAN_FRAMES_ONLY);
    let kind: number = text ? FrameKind.text : FrameKind.binary;
    if (this.options.loan) kind = FrameKind.loan;
    if (!this.options.padded) {
      const out = new Uint8Array(1 + bytes.byteLength);
      out[0] = kind;
      out.set(bytes, 1);
      return out.buffer;
    }
    const out = new Uint8Array(paddedFrameLength(PADDED_FRAME_HEADER_BYTES + bytes.byteLength));
    out[0] = kind | PADDED_FRAME_FLAG;
    new DataView(out.buffer).setUint32(1, bytes.byteLength);
    out.set(bytes, PADDED_FRAME_HEADER_BYTES);
    return out.buffer;
  }

  private unframe(sealed: Uint8Array): string | ArrayBuffer {
    const plaintext = unpadFrame(sealed);
    const kind = plaintext[0];
    const body = plaintext.subarray(1);
    if (this.options.loan) {
      if (kind !== FrameKind.loan) throw new LoanFrameRefusedError();
      const out = new Uint8Array(body.byteLength);
      out.set(body);
      return out.buffer;
    }
    // Audit RELAY-8: whatever the (plaintext) ready said, this side never unpacks.
    if (kind === FrameKind.deflatedText || kind === FrameKind.deflatedBinary) {
      throw new Error("Packed frame refused");
    }
    if (kind === FrameKind.text) {
      return FATAL_UTF8_DECODER.decode(body);
    }
    if (kind === FrameKind.binary) {
      const out = new Uint8Array(body.byteLength);
      out.set(body);
      return out.buffer;
    }
    throw new Error(`Unknown frame kind ${kind}`);
  }

  outboundWireByteLength(data: string | ArrayBuffer): number {
    const plaintextBytes = utf8ByteLength(data);
    const encryptedBytes = plaintextBytes + ENCRYPTED_PAYLOAD_OVERHEAD_BYTES;
    // Framed: one kind byte, binary on the wire. Audit RELAY-8: padded, the bucket of the
    // header and the body.
    if (this.options.framedPlaintext && this.options.padded) {
      return (
        paddedFrameLength(PADDED_FRAME_HEADER_BYTES + plaintextBytes) +
        ENCRYPTED_PAYLOAD_OVERHEAD_BYTES
      );
    }
    if (this.options.framedPlaintext) return encryptedBytes + 1;
    if (this.options.binaryCiphertext && data instanceof ArrayBuffer) {
      return encryptedBytes;
    }
    return base64EncryptedWireByteLength(plaintextBytes);
  }

  /**
   * Client side, audit D16: a daemon that echoed `confirm` gets an empty confirm frame as the
   * first session frame, which releases the early frames it holds. Nothing for any other daemon.
   */
  private async sendConfirm(ready: E2EEReadyMessage): Promise<void> {
    const session = this.options.session;
    if (!session?.sendKey || ready.capabilities?.confirm !== true) return;
    const counter = session.sendCounter;
    session.sendCounter += 1;
    // Audit RELAY-10: `[0x04] ‖ T`, unpadded. A daemon from D16 on reads the kind byte only.
    const transcript = this.transcript ?? new Uint8Array(0);
    const frame = new Uint8Array(1 + transcript.byteLength);
    frame[0] = FrameKind.confirm;
    frame.set(transcript, 1);
    await this.transport.send(encryptWithCounter(session.sendKey, counter, frame.buffer));
  }

  private async flushPendingSends(): Promise<void> {
    if (this.state !== "open") return;
    const pending = this.pendingSends;
    this.pendingSends = [];
    for (const item of pending) {
      await this.send(item);
    }
  }

  private async handleDaemonRehello(message: E2EEHelloMessage): Promise<void> {
    if (!this.options.daemonKeyPair) return;
    const clientPublicKey = importPublicKey(message.key);
    const retryKey = deriveSharedKey(this.options.daemonKeyPair.secretKey, clientPublicKey);
    if (!keysEqual(retryKey, this.sharedKey)) return this.rejectKeyRotation();
    await this.sendReadyForRetry();
  }

  private async sendReadyForRetry(): Promise<void> {
    // Audit RELAY-10: the same text as the first ready, which the transcript covers.
    await this.transport.send(this.options.readyText ?? JSON.stringify(readyMessage(this.options)));
  }

  /**
   * Client side, on `e2ee_ready`: a daemon that agreed to session keys named its ephemeral key,
   * and both directions switch to their own key. An older daemon agrees to nothing and the
   * channel stays on the handshake key.
   */
  private finishClientSession(ready: E2EEReadyMessage, framedPlaintext: boolean): void {
    const pending = this.options.session?.pending;
    if (!pending) return;
    if (!framedPlaintext || ready.capabilities?.sessionKeys !== true) {
      // A loan proof always rides on session keys; a daemon that drops them is not one to trust.
      // Audit RELAY-10: nor does any daemon that reads an account or guest proof (5.0.0 on).
      if (this.options.loan || this.options.provedPeer) {
        throw new ChannelDowngradeError("no session keys for a proven handshake");
      }
      // Audit RELAY-10 (D): a credential waiting for the session keys never goes without them.
      if (
        this.options.credentialsNeedSessionKeys &&
        this.pendingSends.some((item) => typeof item === "string" && carriesSessionCredential(item))
      ) {
        throw new ChannelDowngradeError("a credential needs session keys");
      }
      this.options.session = undefined;
      return;
    }
    if (!ready.key) throw new Error("Session keys agreed without a daemon key");
    const daemonEphemeralPublicKey = importPublicKey(ready.key);
    const keys = deriveSessionKeys({
      role: "client",
      ourEphemeral: pending.ephemeral,
      daemonStatic: pending.daemonStaticPublicKey,
      peerEphemeralPublicKey: daemonEphemeralPublicKey,
      clientEphemeralPublicKey: pending.ephemeral.publicKey,
      daemonEphemeralPublicKey,
    });
    pending.ephemeral.secretKey.fill(0);
    this.options.session = { ...keys, sendCounter: 0, receiveCounter: 0 };
  }

  /**
   * A frame under session keys. Binary frames carry the next counter in their nonce; anything
   * else fails the channel. Text frames are a daemon's early frames from the client (sealed before
   * the client read the ready), accepted only before the first session frame and once each.
   * Decryption runs before any await, so the counter follows arrival order.
   */
  private receiveSessionFrame(message: TransportMessage): void {
    const session = this.options.session;
    if (!session) return;
    let opened: Uint8Array | null = null;
    try {
      if (message.isBinary) {
        if (!session.receiveKey) throw new Error("Session frame before session keys");
        opened = openWithCounter(
          session.receiveKey,
          session.receiveCounter,
          requireArrayBuffer(message.data),
        );
        session.receiveCounter += 1;
        session.sawSessionFrame = true;
        this.sessionFrameOpened(session, opened);
      } else {
        const text = decodeTransportText(message.data);
        if (text.trim().startsWith("{")) {
          // Handshake retries and stray readies stay plaintext; anything else is not ours.
          const parsed: unknown = JSON.parse(text);
          if (isE2EEHelloMessage(parsed) && this.options.daemonKeyPair) {
            void this.handleDaemonRehello(parsed).catch(() => undefined);
            return;
          }
          if (isE2EEHelloMessage(parsed) || isE2EEReadyMessage(parsed)) return;
          throw new Error("Received plaintext frame on encrypted channel");
        }
        this.receiveEarlyFrame(session, text);
        return;
      }
    } catch (error) {
      this.failChannel(error);
      return;
    }
    // The first session frame proves the client live: what it pipelined goes first, in order.
    const held = session.heldEarly;
    if (held && held.length > 0) {
      session.heldEarly = [];
      for (const early of held) this.events.onmessage?.(early);
    }
    if (opened[0] === FrameKind.confirm) return;
    try {
      const plaintext = this.unframe(opened);
      if (this.state === "open") this.events.onmessage?.(plaintext);
    } catch (error) {
      this.failChannel(error);
    }
  }

  /**
   * Daemon side: one early frame (base64, sealed under the handshake key), accepted only before
   * the first session frame and once per nonce. Audit D16: a confirming client's frame is held
   * until a session frame proves the client live, which a replayed handshake never produces.
   * Throws on anything refused.
   */
  private receiveEarlyFrame(session: SessionKeyState, text: string): void {
    // A loan client pipelines nothing: an early (text) frame is not a loan frame.
    if (this.options.loan) throw new LoanFrameRefusedError();
    if (!session.earlyNonces || session.sawSessionFrame) {
      throw new Error("Early frame after the session began");
    }
    const bundle = new Uint8Array(base64ToArrayBuffer(text));
    const nonceKey = arrayBufferToBase64(bundle.slice(0, NONCE_BYTES).buffer);
    if (session.earlyNonces.has(nonceKey)) throw new Error("Replayed early frame");
    session.earlyNonces.add(nonceKey);
    if (!isConfirmingEarlyNonce(bundle) && session.confirm) {
      // Audit RELAY-6: a client whose hello asked for `confirm` marks every early frame (the web,
      // desktop, iOS and Android channels all do); an unmarked one under its handshake is not its.
      throw new Error("Unmarked early frame from a confirming client");
    }
    const plaintext = FATAL_UTF8_DECODER.decode(decrypt(this.sharedKey, bundle.buffer));
    if (!isConfirmingEarlyNonce(bundle)) {
      this.events.onmessage?.(plaintext);
      return;
    }
    const held = (session.heldEarly ??= []);
    if (held.length >= MAX_HELD_EARLY_FRAMES) throw new Error("Too many early frames");
    held.push(plaintext);
  }

  /**
   * A session frame opened. Client side, audits RELAY-6 and RELAY-10: the daemon sealed it, so the
   * level its ready named is pinned. Daemon side, audit RELAY-10: the first one from a confirming
   * client must be the confirm, with the transcript when asked (throws).
   */
  private sessionFrameOpened(session: SessionKeyState, opened: Uint8Array): void {
    if (!this.options.daemonKeyPair) {
      if (!this.awaitingDaemonConfirm) return;
      this.awaitingDaemonConfirm = false;
      const level = this.levelPending;
      this.levelPending = null;
      checkDaemonConfirmFrame(opened, this.transcript);
      if (level && this.options.timedProofDaemonKeyB64) {
        pinDaemonChannelLevel(this.options.timedProofDaemonKeyB64, level, true);
      }
      return;
    }
    if (session.confirm && !session.confirmed) {
      const transcript = session.expectedTranscript;
      checkConfirmFrame(session, opened);
      if (session.transcript && transcript) this.sendDaemonConfirm(session, transcript);
    }
  }

  /**
   * Daemon side, audit RELAY-10: a transcript client's confirm checked out, so the daemon answers
   * with its own, `[0x04] ‖ T` under its session key (frame 0, unpadded), and then sends what
   * waited for it, in order. That frame is the client's proof that this daemon checked T, which is
   * what lets the client pin the level the ready named.
   */
  private sendDaemonConfirm(session: SessionKeyState, transcript: Uint8Array): void {
    if (!session.sendKey) return;
    const counter = session.sendCounter;
    session.sendCounter += 1;
    const frame = new Uint8Array(1 + transcript.byteLength);
    frame[0] = FrameKind.confirm;
    frame.set(transcript, 1);
    try {
      void Promise.resolve(
        this.transport.send(encryptWithCounter(session.sendKey, counter, frame.buffer)),
      ).catch((error: unknown) => this.failChannel(error));
    } catch (error) {
      this.failChannel(error);
      return;
    }
    const held = this.heldForConfirm;
    this.heldForConfirm = [];
    // Each send takes its counter before its first await, so these leave in order.
    for (const item of held) this.send(item.data).then(item.sent, item.failed);
  }

  /** Client side: a ready that could not be finished; a downgrade is also reported. */
  private failHandshake(error: unknown): void {
    if (error instanceof ChannelDowngradeError) this.refuseDowngrade(error);
    else this.failChannel(error);
  }

  /** Audit RELAY-10: a refused downgrade is reported (`onerror`) and closes like a failure. */
  private refuseDowngrade(error: unknown): void {
    const err = error instanceof Error ? error : new Error(String(error));
    this.events.onerror?.(err);
    this.failChannel(err);
  }

  /**
   * Audit RELAY-10: what waited for the client's confirm goes with the channel (its callers are
   * let go, as the client's own queue lets them go).
   */
  private dropHeldForConfirm(): void {
    for (const held of this.heldForConfirm.splice(0)) held.sent();
  }

  private failChannel(error: unknown): void {
    const err = error instanceof Error ? error : new Error(String(error));
    this.state = "closed";
    this.dropHeldForConfirm();
    try {
      this.transport.close(failureCloseCode(err), err.message);
    } catch {
      // ignore
    }
  }

  private rejectKeyRotation(): void {
    this.state = "closed";
    this.transport.close(REHANDSHAKE_REJECTION_CODE, REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON);
  }

  close(code = 1000, reason = "Normal closure"): void {
    this.state = "closed";
    this.dropHeldForConfirm();
    this.transport.close(code, reason);
  }

  isOpen(): boolean {
    return this.state === "open";
  }

  onTransitionToOpen(cb: () => void): void {
    this.onOpenCallbacks.push(cb);
  }

  onClose(cb: () => void): void {
    this.onCloseCallbacks.push(cb);
  }
}

/**
 * Daemon side, audit RELAY-10: the first session frame from a client whose hello asked for
 * `confirm` (every such client sends the confirm first). A body must be the transcript hash of the
 * hello as read and the ready as sent; a bare confirm passes only when the hello did not ask for
 * `transcript` (the phones before RELAY-10). Runs before held early frames are released. Throws.
 */
function checkConfirmFrame(session: SessionKeyState, opened: Uint8Array): void {
  if (opened[0] !== FrameKind.confirm) throw new Error("E2EE confirm frame missing");
  const body = opened.subarray(1);
  if (body.byteLength === 0) {
    if (session.transcript) throw new Error("E2EE transcript missing");
  } else if (!session.expectedTranscript || !keysEqual(body, session.expectedTranscript)) {
    throw new Error("E2EE transcript mismatch");
  }
  session.confirmed = true;
  session.expectedTranscript = undefined;
}

/**
 * Client side, audit RELAY-10: the daemon's first session frame after a ready that echoed
 * `transcript` must be its confirm, `[0x04] ‖ T` with this side's T (compared in constant time).
 * Throws; the channel then fails and nothing is pinned.
 */
function checkDaemonConfirmFrame(opened: Uint8Array, transcript: Uint8Array | null): void {
  if (opened[0] !== FrameKind.confirm) throw new Error("E2EE daemon confirm frame missing");
  if (!transcript || !keysEqual(opened.subarray(1), transcript)) {
    throw new Error("E2EE transcript mismatch");
  }
}

/** 4403 for a frame a loan channel refuses, the channel's own failure code for anything else. */
function failureCloseCode(error: Error): number {
  return error instanceof LoanFrameRefusedError
    ? LOAN_FRAME_REFUSED_CLOSE_CODE
    : CHANNEL_FAILURE_CLOSE_CODE;
}

function decodeTransportText(data: string | ArrayBuffer): string {
  return typeof data === "string" ? data : UTF8_DECODER.decode(data);
}

function requireArrayBuffer(data: string | ArrayBuffer): ArrayBuffer {
  if (data instanceof ArrayBuffer) return data;
  throw new Error("Binary WebSocket frame did not contain bytes");
}

function decodeLegacyPlaintext(data: ArrayBuffer): string | ArrayBuffer {
  try {
    return FATAL_UTF8_DECODER.decode(data);
  } catch {
    return data;
  }
}

function decodePlaintext(data: ArrayBuffer, isBinary: boolean | null): string | ArrayBuffer {
  if (isBinary === true) return data;
  if (isBinary === false) return FATAL_UTF8_DECODER.decode(data);
  return decodeLegacyPlaintext(data);
}

// Stateless without `stream`, so one of each serves every frame.
const UTF8_ENCODER = new TextEncoder();
const UTF8_DECODER = new TextDecoder();
const FATAL_UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });

/** UTF-8 length without encoding; a lone surrogate counts as U+FFFD (3 bytes), as TextEncoder writes it. */
function utf8ByteLength(data: string | ArrayBuffer): number {
  if (typeof data !== "string") return data.byteLength;
  let bytes = 0;
  for (let i = 0; i < data.length; i++) {
    const unit = data.charCodeAt(i);
    if (unit < 0x80) bytes += 1;
    else if (unit < 0x800) bytes += 2;
    else if (unit >= 0xd800 && unit <= 0xdbff && i + 1 < data.length) {
      const next = data.charCodeAt(i + 1);
      if (next >= 0xdc00 && next <= 0xdfff) {
        bytes += 4;
        i++;
      } else bytes += 3;
    } else bytes += 3;
  }
  return bytes;
}

function keysEqual(a: Uint8Array, b: Uint8Array): boolean {
  if (a.byteLength !== b.byteLength) return false;
  let difference = 0;
  for (let i = 0; i < a.byteLength; i += 1) {
    difference |= a[i] ^ b[i];
  }
  return difference === 0;
}
server-join.ts The join secret in a server's install command, which proves the server's key and your account key to each other. packages/relay/src/server-join.ts · 106 lines · last changed 2026-10-06 in b29a667bee
/**
 * Round 36 S3: first-use trust for a new server, without trusting the cloud that carries it.
 *
 * The app that makes a server's install command also makes a join secret (32 random bytes, hex)
 * and puts it in the command it shows — never in a request, so the cloud never holds it. The
 * installer leaves it with the worker (`join-secret`, 0600). Both sides then prove the other's
 * key with it:
 *
 * - **The worker's daemon key.** The worker publishes its key with a join proof (HMAC of the
 *   secret over its device id and key). The app signs an unsigned worker key with the account key
 *   only when a secret it made opens that proof, so a worker the cloud invents, or a key it swaps
 *   in, is never signed.
 * - **The account key.** Signing, the app also posts an account pin (HMAC of the secret over the
 *   device id and the account public key). The worker pins the account key a Hub client proves
 *   only when that pin opens with its secret, so the cloud cannot be the first to prove one.
 *
 * HMAC-SHA-512 over tweetnacl (crypto.ts), truncated to 32 bytes, so every platform runs the same
 * code and the cloud learns nothing it could use about the secret.
 */

import nacl from "tweetnacl";
import { fromByteArray, toByteArray } from "base64-js";
import { concatBytes, hmacSha512, utf8 } from "./crypto.js";

export const JOIN_SECRET_BYTES = 32;
const PROOF_BYTES = 32;
const KEY_CONTEXT = "shuriken/server-join/daemon-key/v1";
const ACCOUNT_CONTEXT = "shuriken/server-join/account-key/v1";

/** A new join secret: 32 random bytes as 64 hex digits (safe as a shell or PowerShell word). */
export function newJoinSecret(): string {
  return toHex(nacl.randomBytes(JOIN_SECRET_BYTES));
}

/** The secret's bytes, or null when `hex` is not exactly one. */
export function parseJoinSecret(hex: string): Uint8Array | null {
  const trimmed = hex.trim().toLowerCase();
  if (!/^[0-9a-f]{64}$/.test(trimmed)) return null;
  const bytes = new Uint8Array(JOIN_SECRET_BYTES);
  for (let i = 0; i < JOIN_SECRET_BYTES; i += 1) {
    bytes[i] = Number.parseInt(trimmed.slice(i * 2, i * 2 + 2), 16);
  }
  return bytes;
}

function toHex(bytes: Uint8Array): string {
  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}

function mac(secretHex: string, context: string, deviceId: string, keyB64: string): Uint8Array {
  const secret = parseJoinSecret(secretHex);
  if (!secret) throw new Error("Not a join secret");
  const tag = hmacSha512(secret, concatBytes(utf8(`${context}\n${deviceId}\n${keyB64}`)));
  secret.fill(0);
  return tag.slice(0, PROOF_BYTES);
}

function equalProof(expected: Uint8Array, proofB64: string | null | undefined): boolean {
  if (!proofB64) return false;
  let given: Uint8Array;
  try {
    given = toByteArray(proofB64);
  } catch {
    return false;
  }
  if (given.byteLength !== expected.byteLength) return false;
  let diff = 0;
  for (let i = 0; i < given.byteLength; i += 1) diff |= given[i]! ^ expected[i]!;
  return diff === 0;
}

/** What a worker publishes with its daemon key: proof the installing app's secret is here. */
export function joinKeyProof(secretHex: string, deviceId: string, daemonKeyB64: string): string {
  return fromByteArray(mac(secretHex, KEY_CONTEXT, deviceId, daemonKeyB64));
}

export function verifyJoinKeyProof(input: {
  secret: string;
  deviceId: string;
  daemonKeyB64: string;
  proof: string | null | undefined;
}): boolean {
  if (!parseJoinSecret(input.secret)) return false;
  return equalProof(
    mac(input.secret, KEY_CONTEXT, input.deviceId, input.daemonKeyB64),
    input.proof,
  );
}

/** What the app posts when it signs the worker's key: the account key this secret vouches for. */
export function joinAccountPin(secretHex: string, deviceId: string, accountKeyB64: string): string {
  return fromByteArray(mac(secretHex, ACCOUNT_CONTEXT, deviceId, accountKeyB64));
}

export function verifyJoinAccountPin(input: {
  secret: string;
  deviceId: string;
  accountKeyB64: string;
  pin: string | null | undefined;
}): boolean {
  if (!parseJoinSecret(input.secret)) return false;
  return equalProof(
    mac(input.secret, ACCOUNT_CONTEXT, input.deviceId, input.accountKeyB64),
    input.pin,
  );
}

Sealed to one device or account

push-seal.ts A push notification sealed to one phone's P-256 key. packages/relay/src/push-seal.ts · 122 lines · last changed 2026-10-06 in ff3c55bad4
/// <reference lib="dom" />
/**
 * Round 36 S4: a push's words sealed to one device, so the cloud that forwards it and the push
 * service that carries it (FCM, a browser's push service) hold ciphertext only.
 *
 * ECIES on P-256, the curve every platform has natively (WebCrypto in a worker daemon, a browser
 * and its service worker; JCA on Android, from API 21, with no library):
 *
 *   e        = a fresh P-256 key pair, one per seal
 *   shared   = ECDH(e, recipient)                         (32-byte x coordinate)
 *   key      = HKDF-SHA-256(shared, salt = ePub ‖ recipientPub, info = "shuriken/push/v1", 32)
 *   sealed   = base64( 0x01 ‖ ePub (65) ‖ iv (12) ‖ AES-256-GCM(key, iv, plaintext, aad = info) )
 *
 * The recipient's public key is the device's push key (65-byte uncompressed point, base64), which
 * the device signed with the account key (account-key.ts, purpose "push"); a sender checks that
 * signature first, so a key the cloud swapped in gets nothing.
 */

import { fromByteArray, toByteArray } from "base64-js";

const VERSION = 1;
const INFO = "shuriken/push/v1";
const POINT_BYTES = 65;
const IV_BYTES = 12;

function subtle(): SubtleCrypto {
  const value = globalThis.crypto?.subtle;
  if (!value) throw new Error("WebCrypto is not available");
  return value;
}

function concat(...parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
  const out = new Uint8Array(parts.reduce((sum, part) => sum + part.byteLength, 0));
  let offset = 0;
  for (const part of parts) {
    out.set(part, offset);
    offset += part.byteLength;
  }
  return out;
}

const encodeInfo = () => new TextEncoder().encode(INFO);

async function messageKey(
  shared: ArrayBuffer,
  senderPoint: Uint8Array,
  recipientPoint: Uint8Array,
  usage: "encrypt" | "decrypt",
): Promise<CryptoKey> {
  const material = await subtle().importKey("raw", shared, "HKDF", false, ["deriveKey"]);
  return subtle().deriveKey(
    {
      name: "HKDF",
      hash: "SHA-256",
      salt: concat(senderPoint, recipientPoint),
      info: encodeInfo(),
    },
    material,
    { name: "AES-GCM", length: 256 },
    false,
    [usage],
  );
}

function importPoint(point: Uint8Array): Promise<CryptoKey> {
  if (point.byteLength !== POINT_BYTES || point[0] !== 4) {
    throw new Error("Push key must be an uncompressed P-256 point");
  }
  return subtle().importKey("raw", concat(point), { name: "ECDH", namedCurve: "P-256" }, false, []);
}

/** Seals `plaintext` (UTF-8 text, usually JSON) to the device whose push key is `recipientB64`. */
export async function sealForDevice(recipientB64: string, plaintext: string): Promise<string> {
  const recipientPoint = toByteArray(recipientB64);
  const recipient = await importPoint(recipientPoint);
  const ephemeral = (await subtle().generateKey({ name: "ECDH", namedCurve: "P-256" }, false, [
    "deriveBits",
  ])) as CryptoKeyPair;
  const senderPoint = new Uint8Array(await subtle().exportKey("raw", ephemeral.publicKey));
  const shared = await subtle().deriveBits(
    { name: "ECDH", public: recipient },
    ephemeral.privateKey,
    256,
  );
  const key = await messageKey(shared, senderPoint, recipientPoint, "encrypt");
  const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_BYTES));
  const sealed = new Uint8Array(
    await subtle().encrypt(
      { name: "AES-GCM", iv, additionalData: encodeInfo() },
      key,
      new TextEncoder().encode(plaintext),
    ),
  );
  return fromByteArray(concat(new Uint8Array([VERSION]), senderPoint, iv, sealed));
}

/** Opens a sealed push with the device's own key pair (`privateKey`: ECDH P-256, may be stored). */
export async function openSealedPush(
  privateKey: CryptoKey,
  recipientB64: string,
  sealedB64: string,
): Promise<string> {
  const bytes = toByteArray(sealedB64);
  if (bytes.byteLength < 1 + POINT_BYTES + IV_BYTES + 16 || bytes[0] !== VERSION) {
    throw new Error("Not a sealed push");
  }
  const senderPoint = bytes.subarray(1, 1 + POINT_BYTES);
  const iv = bytes.subarray(1 + POINT_BYTES, 1 + POINT_BYTES + IV_BYTES);
  const ciphertext = bytes.subarray(1 + POINT_BYTES + IV_BYTES);
  const shared = await subtle().deriveBits(
    { name: "ECDH", public: await importPoint(senderPoint) },
    privateKey,
    256,
  );
  const key = await messageKey(shared, senderPoint, toByteArray(recipientB64), "decrypt");
  const opened = await subtle().decrypt(
    { name: "AES-GCM", iv: concat(iv), additionalData: encodeInfo() },
    key,
    concat(ciphertext),
  );
  return new TextDecoder().decode(opened);
}
daemon-seal.ts A notification reply or a webhook sealed to one computer's key, so the cloud stores ciphertext while it waits. packages/relay/src/daemon-seal.ts · 53 lines · last changed 2026-10-23 in 0c8d35d092
/**
 * Security audit, data-map Gap 2: text that passes through the cloud for one computer's daemon,
 * sealed to that daemon's key so the Hub (and its 30 days of restore points) holds ciphertext.
 *
 *   sealed = base64( 0x01 ‖ ephemeral X25519 public (32) ‖ nonce (24) ‖ box(JSON { p, b, t }) )
 *
 * `p` is the purpose and `b` what the text is bound to (a notification's action nonce, a Hub
 * event's type): a sealed reply does not open on another notification, nor a sealed body on
 * another kind of event. Anyone with the daemon's public key can seal, the cloud included, so
 * this is confidentiality for what passes through, not proof of who sent it.
 * The recipient key is the daemon's static key (`daemon-keypair.json`), the one its relay
 * handshake uses; a phone takes it only from a route it already pinned for that computer, whose
 * key the account key signed (cloud-hosts.ts admitWorkerKey).
 */

import { importPublicKey, utf8 } from "./crypto.js";
import { openAnonymous, sealAnonymous } from "./source-share.js";

const VERSION = 1;

export type DaemonSealPurpose = "agent-reply" | "hub-event";

/** Seals `text` to the daemon whose static public key is `daemonPublicKeyB64`. */
export function sealForDaemon(input: {
  daemonPublicKeyB64: string;
  purpose: DaemonSealPurpose;
  binding: string;
  text: string;
}): string {
  const recipient = importPublicKey(input.daemonPublicKeyB64);
  const inner = JSON.stringify({ p: input.purpose, b: input.binding, t: input.text });
  return sealAnonymous(recipient, VERSION, utf8(inner));
}

/** The text sealed to this daemon for `purpose` and `binding`, or null (anything else). */
export function openForDaemon(input: {
  daemonSecretKey: Uint8Array;
  purpose: DaemonSealPurpose;
  binding: string;
  sealed: string;
}): string | null {
  const opened = openAnonymous(input.daemonSecretKey, VERSION, input.sealed);
  if (!opened) return null;
  try {
    const parsed = JSON.parse(new TextDecoder().decode(opened)) as unknown;
    if (typeof parsed !== "object" || parsed === null) return null;
    const { p, b, t } = parsed as Record<string, unknown>;
    if (p !== input.purpose || b !== input.binding || typeof t !== "string") return null;
    return t;
  } catch {
    return null;
  }
}
source-share.ts Your account's inbox key, and a sign-in sealed to another Shuriken account's inbox key when you keep each other signed in. packages/relay/src/source-share.ts · 326 lines · last changed 2026-10-22 in c77b2cb0ae
/**
 * Round 59 #13: sharing a source (a `provider-account` vault item: a subscription sign-in or an API
 * key) with another Shuriken account, without the cloud ever seeing the credential
 * (docs/source-sharing.md).
 *
 * - **Inbox key.** Every account has an X25519 key pair derived from its vault key (HKDF, like the
 *   account key), so every device that holds the vault key can open what was sealed to it and no
 *   new secret needs a backup. Its public half is published to the cloud with the account key's
 *   signature over it. A vault rotation derives a new one; the earlier vault keys in the key ring
 *   derive the earlier ones, so an envelope sealed before still opens until the owner re-seals.
 * - **Envelope.** The owner's app seals the source's decrypted vault value (plus the share's id,
 *   both accounts' ids and its expiry) to the recipient's inbox key with an ephemeral key, and signs
 *   what it sealed with its own account key. The recipient checks the signature, that the envelope
 *   names the share it was handed under (the cloud cannot move one share's envelope to another)
 *   and that the owner's account key is the one it pinned when it accepted the share (round 59 bug
 *   hunt: always — a key the envelope carries vouches for nothing, the cloud could mint one).
 * - **Usage.** What the recipient spends on a shared source is reported to the owner sealed to the
 *   owner's inbox key and signed by the recipient's account key: the cloud stores who and which
 *   share, never what was spent.
 */

import nacl from "tweetnacl";
import { fromByteArray, toByteArray } from "base64-js";
import { type AccountSigner, importAccountPublicKey } from "./account-key.js";
import { concatBytes, hkdfSha512, utf8 } from "./crypto.js";
import { cleanPersonCard, type PersonCard } from "./source-link.js";

const INBOX_KEY_INFO = "shuriken/share-inbox/v1";
const INBOX_BINDING_CONTEXT = "shuriken/share-inbox-key/v1";
const ENVELOPE_CONTEXT = "shuriken/source-share/v1";
const ENVELOPE_VERSION = 1;
const USAGE_VERSION = 1;
const USAGE_CONTEXT = "shuriken/source-share-usage/v1";
const VAULT_KEY_BYTES = 32;

/** An account's inbox key pair: X25519 (tweetnacl box). */
export interface InboxKeyPair {
  /** base64, 32 bytes: what the cloud publishes. */
  publicKey: string;
  /** 32 bytes; never leaves the account's devices. */
  secretKey: Uint8Array;
}

/** The inbox key pair of the account whose vault key this is. */
export function deriveInboxKeyPair(vaultKey: Uint8Array): InboxKeyPair {
  if (vaultKey.byteLength !== VAULT_KEY_BYTES) {
    throw new Error(`Vault key must be ${VAULT_KEY_BYTES} bytes`);
  }
  const seed = hkdfSha512(vaultKey, new Uint8Array(0), utf8(INBOX_KEY_INFO), 32);
  const pair = nacl.box.keyPair.fromSecretKey(seed);
  return { publicKey: fromByteArray(pair.publicKey), secretKey: pair.secretKey };
}

function inboxBindingMessage(inboxKeyB64: string): Uint8Array {
  return utf8(`${INBOX_BINDING_CONTEXT}\n${inboxKeyB64}`);
}

/** The account key's signature that `inboxKeyB64` is this account's inbox key. */
export async function signInboxKey(account: AccountSigner, inboxKeyB64: string): Promise<string> {
  if (!isBoxKey(inboxKeyB64)) throw new Error("Not an inbox key");
  return fromByteArray(await account.sign(inboxBindingMessage(inboxKeyB64)));
}

/** True only when the account key signed `inboxKeyB64` as its inbox key. */
export function verifyInboxKey(input: {
  accountKey: string;
  inboxKey: string;
  signature: string | null | undefined;
}): boolean {
  const accountKey = importAccountPublicKey(input.accountKey);
  const signature = decode(input.signature ?? "", nacl.sign.signatureLength);
  if (!accountKey || !signature || !isBoxKey(input.inboxKey)) return false;
  return nacl.sign.detached.verify(inboxBindingMessage(input.inboxKey), signature, accountKey);
}

/** What a share hands the recipient. `value` is the source's decrypted vault value. */
export interface SourceShareGrant<Value = unknown> {
  shareId: string;
  /** The owner's vault item id of the source. */
  sourceId: string;
  ownerUserId: string;
  recipientUserId: string;
  /** ISO time the share ends, or null for until revoked. */
  expiresAt: string | null;
  /** Bumped with each re-seal (a refreshed token, a new key): a lower one never replaces it. */
  revision: number;
  value: Value;
  /**
   * Round 83 #41: who shared it, as the recipient sees them (the owner's name and picture). Signed
   * with the grant; a card that is not one a card may carry is left out when opened.
   */
  card?: PersonCard;
}

function envelopeMessage(recipientInboxKey: string, grantJson: string): Uint8Array {
  return utf8(`${ENVELOPE_CONTEXT}\n${recipientInboxKey}\n${grantJson}`);
}

/**
 * Seals a grant to the recipient's inbox key, signed by the owner's account key:
 * base64(0x01 ‖ ephemeral public (32) ‖ nonce (24) ‖ box(JSON { g, k, s })).
 */
export async function sealSourceShare(input: {
  grant: SourceShareGrant;
  recipientInboxKey: string;
  owner: AccountSigner;
}): Promise<string> {
  const recipient = decode(input.recipientInboxKey, nacl.box.publicKeyLength);
  if (!recipient) throw new Error("Not an inbox key");
  const grantJson = JSON.stringify(input.grant);
  const signature = await input.owner.sign(envelopeMessage(input.recipientInboxKey, grantJson));
  const inner = JSON.stringify({
    g: grantJson,
    k: fromByteArray(input.owner.publicKey),
    s: fromByteArray(signature),
  });
  return sealAnonymous(recipient, ENVELOPE_VERSION, utf8(inner));
}

export interface OpenedSourceShare<Value = unknown> {
  grant: SourceShareGrant<Value>;
  /** The owner's account public key (base64) that signed it; the recipient pins it. */
  ownerAccountKey: string;
}

/**
 * Opens an envelope with any of the recipient's inbox secret keys (the current one and those of
 * the key ring), or null when it is not for this recipient, not signed, names another share or
 * another pair of accounts, or is signed by an account key the recipient does not trust for this
 * owner.
 */
export function openSourceShare<Value = unknown>(input: {
  sealed: string;
  inboxKeys: readonly InboxKeyPair[];
  expect: { shareId: string; ownerUserId: string; recipientUserId: string };
  /**
   * Whether this is the owner's account key the recipient pinned when it accepted the share (or
   * one the owner's rotations lead to from it). Required: the key inside the envelope proves only
   * that whoever sealed it holds that key, and anyone — the cloud included — can mint one.
   */
  trustsOwnerKey: (accountKeyB64: string) => boolean;
}): OpenedSourceShare<Value> | null {
  for (const inbox of input.inboxKeys) {
    const opened = openAnonymous(inbox.secretKey, ENVELOPE_VERSION, input.sealed);
    if (!opened) continue;
    let parsed: unknown;
    try {
      parsed = JSON.parse(new TextDecoder().decode(opened));
    } catch {
      return null;
    }
    if (!isRecord(parsed)) return null;
    const { g, k, s } = parsed;
    if (typeof g !== "string" || typeof k !== "string" || typeof s !== "string") return null;
    const ownerKey = importAccountPublicKey(k);
    const signature = decode(s, nacl.sign.signatureLength);
    if (!ownerKey || !signature) return null;
    if (!nacl.sign.detached.verify(envelopeMessage(inbox.publicKey, g), signature, ownerKey)) {
      return null;
    }
    // A caller that passed no pin (an untyped one) trusts nothing.
    if (typeof input.trustsOwnerKey !== "function" || !input.trustsOwnerKey(k)) return null;
    let grant: unknown;
    try {
      grant = JSON.parse(g);
    } catch {
      return null;
    }
    if (!isGrant(grant)) return null;
    const { expect } = input;
    if (
      grant.shareId !== expect.shareId ||
      grant.ownerUserId !== expect.ownerUserId ||
      grant.recipientUserId !== expect.recipientUserId
    ) {
      return null;
    }
    const taken = grant as SourceShareGrant<Value>;
    if (taken.card !== undefined) {
      const card = cleanPersonCard(taken.card);
      if (card) taken.card = card;
      else delete taken.card;
    }
    return { grant: taken, ownerAccountKey: k };
  }
  return null;
}

/** Whether a grant still holds at `nowMs`: no expiry, or one still ahead. */
export function sourceShareActive(grant: { expiresAt: string | null }, nowMs: number): boolean {
  if (grant.expiresAt === null) return true;
  const end = Date.parse(grant.expiresAt);
  return Number.isFinite(end) && end > nowMs;
}

/** What a recipient's device spent on a shared source, reported to its owner. */
export interface ShareUsageReport {
  /**
   * The recipient daemon's record id. Also posted in the clear so the cloud stores a report once;
   * inside the box so the owner counts it once whatever the cloud replays.
   */
  id: string;
  shareId: string;
  /** ISO time of the last turn it covers. */
  at: string;
  turns: number;
  costUsd: number;
  inputTokens: number;
  outputTokens: number;
}

function usageMessage(ownerInboxKey: string, reportJson: string): Uint8Array {
  return utf8(`${USAGE_CONTEXT}\n${ownerInboxKey}\n${reportJson}`);
}

/**
 * Seals a usage report to the owner's inbox key, signed by the recipient's account key (anyone can
 * seal to a public key; the signature says which account spent it).
 */
export async function sealShareUsage(input: {
  ownerInboxKey: string;
  report: ShareUsageReport;
  recipient: AccountSigner;
}): Promise<string> {
  const owner = decode(input.ownerInboxKey, nacl.box.publicKeyLength);
  if (!owner) throw new Error("Not an inbox key");
  const reportJson = JSON.stringify(input.report);
  const signature = await input.recipient.sign(usageMessage(input.ownerInboxKey, reportJson));
  const inner = JSON.stringify({
    r: reportJson,
    k: fromByteArray(input.recipient.publicKey),
    s: fromByteArray(signature),
  });
  return sealAnonymous(owner, USAGE_VERSION, utf8(inner));
}

/**
 * The report, or null when it is not sealed to these keys, not signed by an account key the owner
 * trusts for the recipient, or names another share.
 */
export function openShareUsage(input: {
  sealed: string;
  inboxKeys: readonly InboxKeyPair[];
  shareId: string;
  trustsRecipientKey: (accountKeyB64: string) => boolean;
}): ShareUsageReport | null {
  for (const inbox of input.inboxKeys) {
    const opened = openAnonymous(inbox.secretKey, USAGE_VERSION, input.sealed);
    if (!opened) continue;
    try {
      const inner = JSON.parse(new TextDecoder().decode(opened)) as unknown;
      if (!isRecord(inner)) return null;
      const { r, k, s } = inner;
      if (typeof r !== "string" || typeof k !== "string" || typeof s !== "string") return null;
      const recipientKey = importAccountPublicKey(k);
      const signature = decode(s, nacl.sign.signatureLength);
      if (!recipientKey || !signature || !input.trustsRecipientKey(k)) return null;
      if (!nacl.sign.detached.verify(usageMessage(inbox.publicKey, r), signature, recipientKey)) {
        return null;
      }
      const report = JSON.parse(r) as unknown;
      if (!isRecord(report) || report.shareId !== input.shareId) return null;
      const numbers = ["turns", "costUsd", "inputTokens", "outputTokens"] as const;
      if (typeof report.at !== "string" || typeof report.id !== "string") return null;
      if (!numbers.every((key) => typeof report[key] === "number" && report[key] >= 0)) {
        return null;
      }
      return report as unknown as ShareUsageReport;
    } catch {
      return null;
    }
  }
  return null;
}

/** box(message) to `recipient` from a fresh ephemeral key: base64(version ‖ ephemeral ‖ nonce ‖ box). */
export function sealAnonymous(recipient: Uint8Array, version: number, message: Uint8Array): string {
  const ephemeral = nacl.box.keyPair();
  const nonce = nacl.randomBytes(nacl.box.nonceLength);
  const box = nacl.box(message, nonce, recipient, ephemeral.secretKey);
  return fromByteArray(concatBytes(new Uint8Array([version]), ephemeral.publicKey, nonce, box));
}

/** What `sealAnonymous` sealed to `secretKey`'s public half, or null. */
export function openAnonymous(secretKey: Uint8Array, version: number, sealedB64: string) {
  try {
    const sealed = toByteArray(sealedB64);
    const head = 1 + nacl.box.publicKeyLength + nacl.box.nonceLength;
    if (sealed[0] !== version || sealed.byteLength <= head) return null;
    const ephemeral = sealed.subarray(1, 1 + nacl.box.publicKeyLength);
    const nonce = sealed.subarray(1 + nacl.box.publicKeyLength, head);
    return nacl.box.open(sealed.subarray(head), nonce, ephemeral, secretKey);
  } catch {
    return null;
  }
}

function decode(b64: string, bytes: number): Uint8Array | null {
  try {
    const decoded = toByteArray(b64);
    return decoded.byteLength === bytes ? decoded : null;
  } catch {
    return null;
  }
}

function isBoxKey(b64: string): boolean {
  return decode(b64, nacl.box.publicKeyLength) !== null;
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isGrant(value: unknown): value is SourceShareGrant {
  if (!isRecord(value)) return false;
  return (
    typeof value.shareId === "string" &&
    typeof value.sourceId === "string" &&
    typeof value.ownerUserId === "string" &&
    typeof value.recipientUserId === "string" &&
    (value.expiresAt === null || typeof value.expiresAt === "string") &&
    typeof value.revision === "number" &&
    "value" in value
  );
}

Rotation and removing a device

vault-rotation.ts A new vault key sealed to each remaining device's share key, and the old account key signing the new one. packages/relay/src/vault-rotation.ts · 225 lines · last changed 2026-10-22 in 8344f2ee0e
/**
 * Round 36 S3: revocation. Removing a device must cut it off from new data, and a device that
 * held the vault key could have copied it. So removing one rotates the vault key on a trusted
 * device, and the account key derived from it (account-key.ts) rotates with it:
 *
 * - **Shares.** Every app device keeps a share key (X25519, tweetnacl box) whose public half the
 *   account key signed (purpose "share"). The rotating device seals the new vault key to each
 *   remaining device's share key; the cloud carries the sealed share and cannot open it. The
 *   removed device has no share, and its old key opens nothing written after.
 * - **Rotation statements.** The old account key signs the new one with the next epoch. A worker
 *   that pinned the old key follows a chain of statements to the new one and from then on refuses
 *   handshakes proving the old one; a device opening a share checks the new key the same way, so a
 *   cloud that seals a key of its own to a device's (public) share key is refused.
 */

import nacl from "tweetnacl";
import { fromByteArray, toByteArray } from "base64-js";
import {
  type AccountSigner,
  deriveAccountKeyPair,
  exportAccountPublicKey,
  importAccountPublicKey,
} from "./account-key.js";
import { concatBytes, utf8 } from "./crypto.js";

const ROTATION_CONTEXT = "shuriken/account-rotation/v1";
const SHARE_VERSION = 1;
const VAULT_KEY_BYTES = 32;

/** One step of the account key: the key of `epoch`, signed by the key of `epoch - 1`. */
export interface AccountRotation {
  epoch: number;
  /** The new account public key, base64. */
  key: string;
  /** Ed25519 by the previous account key over the context, the epoch and `key`. */
  sig: string;
}

function rotationMessage(epoch: number, keyB64: string): Uint8Array {
  return utf8(`${ROTATION_CONTEXT}\n${epoch}\n${keyB64}`);
}

/** The previous account key vouches for the next one at `epoch`. */
export async function signAccountRotation(
  previous: AccountSigner,
  epoch: number,
  nextKeyB64: string,
): Promise<AccountRotation> {
  if (!Number.isInteger(epoch) || epoch < 1) throw new Error("A rotation's epoch starts at 1");
  if (!importAccountPublicKey(nextKeyB64)) throw new Error("Not an account public key");
  const sig = await previous.sign(rotationMessage(epoch, nextKeyB64));
  return { epoch, key: nextKeyB64, sig: fromByteArray(sig) };
}

function verifiesFrom(previousKeyB64: string, rotation: AccountRotation): boolean {
  const previous = importAccountPublicKey(previousKeyB64);
  if (!previous || !importAccountPublicKey(rotation.key)) return false;
  let sig: Uint8Array;
  try {
    sig = toByteArray(rotation.sig);
  } catch {
    return false;
  }
  if (sig.byteLength !== nacl.sign.signatureLength) return false;
  return nacl.sign.detached.verify(rotationMessage(rotation.epoch, rotation.key), sig, previous);
}

/**
 * Where a chain of rotations leads from a key someone trusts: each next step must carry the next
 * epoch and be signed by the key before it. Steps at or below `fromEpoch` are skipped; the walk
 * stops at the first one that does not follow, so a step the cloud forged or reordered leads
 * nowhere.
 */
export function followAccountRotations(
  trustedKeyB64: string,
  fromEpoch: number,
  rotations: readonly AccountRotation[] | null | undefined,
): { key: string; epoch: number } {
  let key = trustedKeyB64;
  let epoch = fromEpoch;
  const steps = [...(rotations ?? [])].sort((a, b) => a.epoch - b.epoch);
  for (const step of steps) {
    if (step.epoch <= epoch) continue;
    if (step.epoch !== epoch + 1 || !verifiesFrom(key, step)) break;
    key = step.key;
    epoch = step.epoch;
  }
  return { key, epoch };
}

/** One step a walk took: `from` signed `key` as the account key of `epoch`. */
export interface AccountRotationStep {
  from: string;
  epoch: number;
  key: string;
}

/** Steps a worker saw before, by the key that signed them (r83 RELAY-1, account-key.json). */
export type KnownAccountRotations = Readonly<Record<string, { epoch: number; key: string }>>;

/**
 * RELAY-1 (r83): where the account key moved from a key someone trusts, for a worker that follows
 * rotations on its own (no client in between to say which key is next). Stricter than
 * `followAccountRotations`:
 *
 * - every step the trusted key signed counts, wherever the cloud lists it, so a junk step listed
 *   first cannot hide the real one; the signature binds the epoch, so a later epoch than the pin's
 *   is enough (a key pinned as epoch 0 while the account was further on still follows);
 * - a key that signed two different next keys (a removed device still holds the old key and can
 *   sign one of its own), or a step that differs from one in `known`, is a conflict: the walk stops
 *   before that key and names it, and the caller refuses rather than pick one.
 */
export function walkAccountRotations(
  trustedKeyB64: string,
  fromEpoch: number,
  rotations: readonly AccountRotation[] | null | undefined,
  known: KnownAccountRotations = {},
): {
  key: string;
  epoch: number;
  steps: AccountRotationStep[];
  conflict: { from: string } | null;
} {
  const listed = rotations ?? [];
  const signedBy = (from: string): Array<{ epoch: number; key: string }> => {
    const out = new Map<string, { epoch: number; key: string }>();
    for (const step of listed) {
      if (verifiesFrom(from, step)) out.set(`${step.epoch}\n${step.key}`, step);
    }
    return [...out.values()];
  };
  const differs = (a: { epoch: number; key: string }, b: { epoch: number; key: string }) =>
    a.epoch !== b.epoch || a.key !== b.key;
  // A step seen before that the cloud now lists differently: some key signed twice.
  for (const [from, seen] of Object.entries(known)) {
    if (signedBy(from).some((step) => differs(step, seen))) {
      return { key: trustedKeyB64, epoch: fromEpoch, steps: [], conflict: { from } };
    }
  }
  let key = trustedKeyB64;
  let epoch = fromEpoch;
  const steps: AccountRotationStep[] = [];
  for (let guard = 0; guard <= listed.length; guard += 1) {
    const next = signedBy(key);
    const seen = known[key];
    if (seen && !next.some((step) => !differs(step, seen))) next.push(seen);
    if (next.length > 1) return { key, epoch, steps, conflict: { from: key } };
    const only = next[0];
    if (!only || only.epoch <= epoch) break;
    steps.push({ from: key, epoch: only.epoch, key: only.key });
    key = only.key;
    epoch = only.epoch;
  }
  return { key, epoch, steps, conflict: null };
}

/** A device's share key pair: the secret stays on the device, the public half is published. */
export interface ShareKeyPair {
  publicKey: string;
  secretKey: string;
}

export function generateShareKeyPair(): ShareKeyPair {
  const pair = nacl.box.keyPair();
  return { publicKey: fromByteArray(pair.publicKey), secretKey: fromByteArray(pair.secretKey) };
}

/** What a share opens to: the vault key of `epoch`. */
export interface VaultShare {
  epoch: number;
  vaultKey: Uint8Array;
}

/**
 * Seals the vault key of `epoch` to one device's share key: base64(0x01 ‖ ephemeral public (32)
 * ‖ nonce (24) ‖ box). The epoch is inside, so a share cannot be replayed as another epoch's.
 */
export function sealVaultShare(shareKeyB64: string, share: VaultShare): string {
  if (share.vaultKey.byteLength !== VAULT_KEY_BYTES) throw new Error("Not a vault key");
  const recipient = toByteArray(shareKeyB64);
  if (recipient.byteLength !== nacl.box.publicKeyLength) throw new Error("Not a share key");
  const ephemeral = nacl.box.keyPair();
  const nonce = nacl.randomBytes(nacl.box.nonceLength);
  const epoch = new Uint8Array(4);
  new DataView(epoch.buffer).setUint32(0, share.epoch);
  const box = nacl.box(concatBytes(epoch, share.vaultKey), nonce, recipient, ephemeral.secretKey);
  return fromByteArray(
    concatBytes(new Uint8Array([SHARE_VERSION]), ephemeral.publicKey, nonce, box),
  );
}

/** The vault key a share holds, or null when it is not sealed to this secret key. */
export function openVaultShare(secretKeyB64: string, sealedB64: string): VaultShare | null {
  try {
    const sealed = toByteArray(sealedB64);
    const secret = toByteArray(secretKeyB64);
    const head = 1 + nacl.box.publicKeyLength + nacl.box.nonceLength;
    if (sealed[0] !== SHARE_VERSION || sealed.byteLength <= head) return null;
    const ephemeral = sealed.subarray(1, 1 + nacl.box.publicKeyLength);
    const nonce = sealed.subarray(1 + nacl.box.publicKeyLength, head);
    const opened = nacl.box.open(sealed.subarray(head), nonce, ephemeral, secret);
    if (!opened || opened.byteLength !== 4 + VAULT_KEY_BYTES) return null;
    const epoch = new DataView(opened.buffer, opened.byteOffset, 4).getUint32(0);
    return { epoch, vaultKey: opened.slice(4) };
  } catch {
    return null;
  }
}

/**
 * Whether a vault key really is the account's at `epoch`: its account key is where the chain of
 * rotations from a key this device already trusts leads. A share sealed by the cloud (anyone can
 * seal to a public key) with a key of its own fails here.
 */
export function vaultKeyFollows(input: {
  vaultKey: Uint8Array;
  epoch: number;
  trustedKeyB64: string;
  trustedEpoch: number;
  rotations: readonly AccountRotation[] | null | undefined;
}): boolean {
  const next = exportAccountPublicKey(deriveAccountKeyPair(input.vaultKey).publicKey);
  const reached = followAccountRotations(input.trustedKeyB64, input.trustedEpoch, input.rotations);
  return reached.epoch === input.epoch && reached.key === next;
}
vault-rotation.ts The app's side of a rotation: every item encrypted again, the new key wrapped and handed to your other devices. packages/app/src/account/vault-rotation.ts · 531 lines · last changed 2026-10-25 in 3bcc8c991a
import {
  accountSigner,
  deriveAccountKeyPair,
  exportAccountPublicKey,
  generateShareKeyPair,
  openVaultShare,
  sealVaultShare,
  signAccountRotation,
  signDeviceKeyWith,
  vaultKeyFollows,
  verifyDeviceKey,
  type AccountKeyPair,
  type AccountRotation,
  type ShareKeyPair,
} from "@shuriken/client";
import {
  trustedDeviceProofKeyV2,
  unlockAuth,
  unwrapVaultKey,
  wrapVaultKey,
} from "@shuriken/protocol/auth/unlock-key";
import { ACCOUNT_SHARE_KEY } from "./account-secure-store";
import { base64ToBytes, bytesToBase64 } from "./base64";
import type {
  CloudApi,
  CloudDevice,
  VaultInfo,
  VaultItem,
  VaultRotationRequest,
} from "./cloud-api";
import { isUnlockKeyVault, unlockKeyWithKek, type VaultUnlockInfo } from "./passkey-unlock";
import {
  decryptVaultValue,
  encryptVaultValue,
  passwordSecrets,
  unwrapVaultKeyWithKek,
  wrapVaultKeyWithKek,
  WrongPasswordError,
} from "./vault-crypto";
import type { VaultCryptoBackend } from "./vault-crypto-backend-types";
import { bindVaultItem } from "./vault-client";
import {
  TRUSTED_DEVICES_VAULT_ID,
  carryTrustedDevices,
  liveTrustedDevices,
  openTrustedDevices,
  proveTrustedDevice,
  reconcileTrustedDevices,
  signTrustedDevices,
  trustedDevicesProofKey,
  unlockProofKey,
  type AnyProofKey,
  type TrustedDeviceSet,
} from "./trusted-devices";

// Round 36 S3: revocation (relay vault-rotation.ts, docs/cloud.md › Vault key scheme). Removing
// a device rotates the vault key here, on a device the person just proved with the password:
// every vault item is re-encrypted, the new key is wrapped under the same KEK and sealed to each
// remaining device's share key, the old account key signs the new one, and the earlier keys go
// into a key ring sealed with the new one so what was sealed before still opens. The removed
// device keeps only keys that open nothing written after.
//
// Trusted devices (trusted-devices.ts): "each remaining device" is each device in the set this
// device trusts, never each device the cloud lists. The removed device kept the old account key,
// so it can sign a share key under any id; a cloud listing it again (or under a new id) once
// received the new key. Now the share is sealed to the key pinned in the set, the set the vault
// holds is taken only when the old account key signed it for this epoch, and each device in it
// must carry a proof the password just typed here checks (the removed device can sign with the old
// account key, but it does not know the password). The next epoch's set (the proven devices but
// the removed ones) is signed with the new account key and written in the same request. A device
// the cloud lists that is not in the set gets nothing and is shown to the person instead.
//
// Migration 0123: on an unlock-key account the vault key is under U, not the KEK, so the new one
// goes under U too (`vaultUnlockWrap`, proved by `unlockAuth`; unlockKeyScheme below) and the set
// is checked with U's proof key (v2), the KEK's as well when the password opened U.

/** The vault epoch this device's key is for (0 before any rotation). */
export const ACCOUNT_VAULT_EPOCH = "shuriken.vault-epoch";
export { ACCOUNT_SHARE_KEY } from "./account-secure-store";

const VAULT_KEY_BYTES = 32;

function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
  if (a.byteLength !== b.byteLength) return false;
  for (let i = 0; i < a.byteLength; i += 1) if (a[i] !== b[i]) return false;
  return true;
}

/**
 * `backend` with a key ring: a value the current vault key does not open is tried with the
 * earlier ones (what was sealed before a rotation). Any other key is left alone.
 */
export function withVaultKeyRing(
  backend: VaultCryptoBackend,
  ring: () => { current: Uint8Array | null; previous: readonly Uint8Array[] },
): VaultCryptoBackend {
  return {
    ...backend,
    async decrypt(key, iv, data) {
      try {
        return await backend.decrypt(key, iv, data);
      } catch (error) {
        const { current, previous } = ring();
        if (!current || !sameBytes(key, current)) throw error;
        for (const earlier of previous) {
          try {
            return await backend.decrypt(earlier, iv, data);
          } catch {
            // The next one.
          }
        }
        throw error;
      }
    },
  };
}

/** The earlier vault keys a ring sealed with `vaultKey` holds; [] without one. */
export async function openKeyRing(
  crypto: VaultCryptoBackend,
  vaultKey: Uint8Array,
  ring: VaultInfo["keyRing"],
): Promise<Uint8Array[]> {
  if (!ring) return [];
  const opened = (await decryptVaultValue(crypto, vaultKey, {
    id: "vault-key-ring",
    ...ring,
  })) as { keys?: unknown };
  return Array.isArray(opened.keys)
    ? opened.keys
        .filter((entry): entry is string => typeof entry === "string")
        .map(base64ToBytes)
        .filter((key) => key.byteLength === VAULT_KEY_BYTES)
    : [];
}

/** This device's share key pair, made and kept the first time it is asked for. */
export async function loadShareKeyPair(store: {
  get(key: string): Promise<string | null>;
  set(key: string, value: string): Promise<void>;
}): Promise<ShareKeyPair> {
  const stored = await store.get(ACCOUNT_SHARE_KEY).catch(() => null);
  if (stored) {
    try {
      const parsed = JSON.parse(stored) as Partial<ShareKeyPair>;
      if (typeof parsed.publicKey === "string" && typeof parsed.secretKey === "string") {
        return { publicKey: parsed.publicKey, secretKey: parsed.secretKey };
      }
    } catch {
      // Made again below.
    }
  }
  const pair = generateShareKeyPair();
  await store.set(ACCOUNT_SHARE_KEY, JSON.stringify(pair));
  return pair;
}

interface SignedKey {
  key: string;
  signature: string;
}

function signedBy(
  account: AccountKeyPair,
  purpose: "daemon" | "push" | "share",
  deviceId: string,
  key: SignedKey | null | undefined,
): key is SignedKey {
  return Boolean(
    key &&
    verifyDeviceKey({
      accountPublicKey: account.publicKey,
      purpose,
      deviceId,
      keyB64: key.key,
      signatureB64: key.signature,
    }),
  );
}

export class RotationRefusedError extends Error {
  constructor(readonly reason: "no_vault" | "old_scheme" | "unlock_wrap") {
    super(`The vault key cannot be rotated (${reason})`);
    this.name = "RotationRefusedError";
  }
}

/**
 * What a rotation proves itself with. The password (either scheme: on an unlock-key account its
 * KEK opens U), or U itself (an unlock-key account: a passkey tap or the recovery key opened it).
 */
export type RotationProof = { password: string } | { unlockKey: Uint8Array };

/** How the rotation opens the current key, which proof keys check the set, and how it pays. */
interface RotationScheme {
  current: Uint8Array;
  /** Every proof key this device holds now (v1 from the KEK, v2 from U). */
  proofKeys: AnyProofKey[];
  /** The key this device proves itself with, and its version. */
  selfProofKey: AnyProofKey;
  /** The scheme's half of `POST /vault/rotate`: its proof and the new vault key's wrap. */
  request(
    next: Uint8Array,
  ): Promise<
    Pick<VaultRotationRequest, "authKey" | "wrappedVaultKey" | "unlockAuth" | "vaultUnlockWrap">
  >;
  /** Zeroes what this scheme derived (U when it opened it itself). */
  done(): void;
}

/**
 * The password scheme: the KEK opens the vault key, the new one goes under the same KEK, and the
 * auth key proves it (round 36 S3).
 */
async function passwordScheme(
  crypto: VaultCryptoBackend,
  vault: VaultInfo,
  proof: RotationProof,
): Promise<RotationScheme> {
  if (!vault.wrappedKey) throw new RotationRefusedError("no_vault");
  if (vault.scheme !== "authKey") throw new RotationRefusedError("old_scheme");
  if (!("password" in proof)) throw new WrongPasswordError();
  const secrets = await passwordSecrets(crypto, proof.password, vault);
  const current = await unwrapVaultKeyWithKek(crypto, secrets.kek, vault.wrappedKey);
  const proofKey = trustedDevicesProofKey(secrets.kek);
  return {
    current,
    proofKeys: [proofKey],
    selfProofKey: proofKey,
    request: async (next) => ({
      authKey: secrets.authKey,
      wrappedVaultKey: await wrapVaultKeyWithKek(crypto, secrets.kek, next),
    }),
    done: () => undefined,
  };
}

/**
 * Migration 0123, an unlock-key account (docs/cloud.md › Email first, passkeys and the unlock
 * key): the vault key is under U (`vault_unlock_wrap`), so the new one goes under U too, proved
 * by `unlockAuth`. U's own wraps (passkeys, password, recovery key) stay as they are. The set is
 * checked with U's key (proof v2) and, when the password opened U, with the KEK's (v1) as well.
 */
async function unlockKeyScheme(
  crypto: VaultCryptoBackend,
  vault: VaultInfo,
  info: VaultUnlockInfo,
  userId: string,
  proof: RotationProof,
): Promise<RotationScheme> {
  let unlockKey: Uint8Array;
  let owned = false;
  const proofKeys: AnyProofKey[] = [];
  if ("password" in proof) {
    const { kek } = await passwordSecrets(crypto, proof.password, vault);
    const opened = await unlockKeyWithKek(info, userId, kek);
    if (!opened) throw new WrongPasswordError();
    unlockKey = opened;
    owned = true;
    proofKeys.push(trustedDevicesProofKey(kek));
  } else {
    unlockKey = proof.unlockKey;
  }
  const current = info.vaultUnlockWrap
    ? await unwrapVaultKey(unlockKey, info.vaultUnlockWrap, userId)
    : null;
  if (!current) {
    if (owned) unlockKey.fill(0);
    // U opened (the password's or a passkey's wrap) but the vault's wrap under U does not: the
    // cloud's two answers disagree. Nothing is rotated.
    throw new RotationRefusedError("unlock_wrap");
  }
  const v2 = unlockProofKey(await trustedDeviceProofKeyV2(unlockKey));
  proofKeys.push(v2);
  return {
    current,
    proofKeys,
    selfProofKey: v2,
    request: async (next) => ({
      unlockAuth: await unlockAuth(unlockKey),
      vaultUnlockWrap: await wrapVaultKey(unlockKey, next, userId),
    }),
    done: () => {
      if (owned) unlockKey.fill(0);
    },
  };
}

/**
 * Rotates the vault key away from `removedDeviceIds`. Throws WrongPasswordError when the
 * password does not open the vault, and the cloud's 409 when another device changed the vault or
 * rotated it meanwhile (read again and retry). On an unlock-key account the new key is wrapped
 * under U, never the KEK (the cloud answers 426 to that shape).
 */
export async function rotateVaultKey(input: {
  crypto: VaultCryptoBackend;
  api: CloudApi;
  /** The password scheme's way, and an unlock-key account's when the password opens U. */
  password?: string;
  /** An unlock-key account: U, opened here by a passkey or the recovery key (the caller zeroes it). */
  unlockKey?: Uint8Array;
  removedDeviceIds: readonly string[];
  /**
   * The devices this device trusts: its own copy of the set (any epoch; only one for the epoch
   * rotated from counts) and itself with its share key. The vault's copy is read here.
   */
  trusted: { local: TrustedDeviceSet | null; self: { id: string; key: string } | null };
  now?: () => number;
}): Promise<{
  vaultKey: Uint8Array;
  epoch: number;
  previous: Uint8Array[];
  /** The next epoch's set, as written (signed with the new account key). */
  trusted: TrustedDeviceSet;
}> {
  const now = input.now ?? Date.now;
  const { crypto, api } = input;
  const { vault, user } = await api.me();
  const proof: RotationProof = input.unlockKey
    ? { unlockKey: input.unlockKey }
    : { password: input.password ?? "" };
  const scheme = isUnlockKeyVault(vault.unlock)
    ? await unlockKeyScheme(crypto, vault, vault.unlock, user.id, proof)
    : await passwordScheme(crypto, vault, proof);
  try {
    return await rotateWith({ ...input, now, vault, scheme });
  } finally {
    scheme.done();
  }
}

async function rotateWith(input: {
  crypto: VaultCryptoBackend;
  api: CloudApi;
  removedDeviceIds: readonly string[];
  trusted: { local: TrustedDeviceSet | null; self: { id: string; key: string } | null };
  now: () => number;
  vault: VaultInfo;
  scheme: RotationScheme;
}) {
  const { crypto, api, vault, scheme, now } = input;
  const current = scheme.current;
  const fromEpoch = vault.epoch ?? 0;
  const older = await openKeyRing(crypto, current, vault.keyRing).catch(() => []);
  const next = crypto.randomBytes(VAULT_KEY_BYTES);
  const oldAccount = deriveAccountKeyPair(current);
  const newAccount = deriveAccountKeyPair(next);
  const newSigner = accountSigner(newAccount);
  const nextEpoch = fromEpoch + 1;

  // Every item, re-encrypted. One sealed before an earlier rotation opens with the ring. What it
  // opens to is sealed again as it is: a bound item keeps its id, kind and revision (audit
  // VAULT-11), so its ciphertext from before this rotation, which the ring still opens, stays
  // refused for the newer one. One sealed with no revision is left to the keeper pass
  // (vault-client.ts bindLegacyItems), which knows what this device has seen of it.
  const ringBackend = withVaultKeyRing(crypto, () => ({ current, previous: older }));
  const opened: Array<{ item: VaultItem; value: unknown }> = [];
  let cloudTrusted: TrustedDeviceSet | null = null;
  for (const item of await api.listVaultItems()) {
    const value = await decryptVaultValue(ringBackend, current, item);
    opened.push({ item, value });
    if (item.id === TRUSTED_DEVICES_VAULT_ID) {
      const envelope = value as { value?: unknown } | null;
      cloudTrusted = openTrustedDevices(envelope?.value, current, fromEpoch);
    }
  }

  // The devices that stay: the ones this device trusts (its copy and the vault's, for this
  // epoch), but the removed ones. Never one only the cloud vouches for.
  const removed = new Set(input.removedDeviceIds);
  const self = input.trusted.self;
  const selfKey = scheme.selfProofKey;
  const trustedNow = reconcileTrustedDevices({
    epoch: fromEpoch,
    local: input.trusted.local,
    cloud: cloudTrusted,
    self: self && {
      ...self,
      proof: proveTrustedDevice(selfKey, self.id, self.key),
      proofVersion: selfKey instanceof Uint8Array ? 1 : selfKey.version,
    },
    now: now(),
  });
  const nextTrusted = carryTrustedDevices(trustedNow, removed, nextEpoch, scheme.proofKeys);
  const pinned = liveTrustedDevices(nextTrusted);

  const items = [];
  for (const { item, value } of opened) {
    let sealedValue = value;
    if (item.id === TRUSTED_DEVICES_VAULT_ID) {
      const revision = (value as { revision?: unknown } | null)?.revision;
      sealedValue = bindVaultItem(
        item.id,
        item.kind,
        Math.max(typeof revision === "number" ? revision + 1 : 1, Math.floor(now())),
        signTrustedDevices(newAccount, nextTrusted),
      );
    }
    items.push({
      id: item.id,
      version: item.version,
      ...(await encryptVaultValue(crypto, next, sealedValue)),
    });
  }

  // Shares for the trusted devices that stay, sealed to the share key pinned for each, and the
  // new account key's signatures over their keys.
  const devices = (await api.listDevices()).filter((device) => !removed.has(device.id));
  const shareKeys = new Map(
    (await api.listShareKeys()).map((entry) => [entry.deviceId, entry] as const),
  );
  const shares: Array<{ deviceId: string; sealed: string }> = [];
  const signatures = [];
  for (const device of devices) {
    const pinnedKey = pinned.get(device.id);
    const listed = shareKeys.get(device.id);
    const share = pinnedKey && listed?.key === pinnedKey ? listed : undefined;
    // O13: a device not in the trusted set gets nothing from the rotation: no share, and none of
    // its keys signed with the new account key either (its daemon's key, its phone's push key).
    // That includes a device with no share key at all: the removed device holds the old account
    // key, so it can sign in a device of its own that publishes none (or the cloud can leave one
    // out of the listing), and re-signing it would keep pushes (chat text) and a daemon flowing to
    // it after the removal. A device signs its own keys again once its vault opens there
    // (account-service.ts); a headless server's daemon key is signed again by an app that already
    // holds it pinned, at that app's next sync (cloud-hosts.ts admitWorkerKey).
    const signed = await resign(device, share, oldAccount, newSigner, Boolean(pinnedKey));
    if (signed) signatures.push(signed);
    if (pinnedKey) {
      shares.push({
        deviceId: device.id,
        sealed: sealVaultShare(pinnedKey, { epoch: nextEpoch, vaultKey: next }),
      });
    }
  }

  const rotation: AccountRotation = await signAccountRotation(
    accountSigner(oldAccount),
    nextEpoch,
    exportAccountPublicKey(newAccount.publicKey),
  );
  const keyRing = await encryptVaultValue(crypto, next, {
    keys: [current, ...older].map(bytesToBase64),
  });
  const answer = await api.rotateVault({
    fromEpoch,
    ...(await scheme.request(next)),
    rotation,
    keyRing,
    items,
    shares,
    signatures,
  });
  return {
    vaultKey: next,
    epoch: answer.epoch,
    previous: [current, ...older],
    trusted: nextTrusted,
  };
}

async function resign(
  device: CloudDevice,
  share: SignedKey | undefined,
  oldAccount: AccountKeyPair,
  newSigner: ReturnType<typeof accountSigner>,
  /** O13: whether its daemon and push keys are signed again (only a device this one trusts). */
  deviceKeys: boolean,
) {
  const out: {
    deviceId: string;
    publicKey?: SignedKey;
    pushKey?: SignedKey;
    shareKey?: SignedKey;
  } = { deviceId: device.id };
  const daemon =
    device.publicKey && device.publicKeySig
      ? { key: device.publicKey, signature: device.publicKeySig }
      : null;
  if (deviceKeys && signedBy(oldAccount, "daemon", device.id, daemon)) {
    out.publicKey = {
      key: daemon.key,
      signature: await signDeviceKeyWith(newSigner, "daemon", device.id, daemon.key),
    };
  }
  if (deviceKeys && signedBy(oldAccount, "push", device.id, device.pushKey)) {
    out.pushKey = {
      key: device.pushKey.key,
      signature: await signDeviceKeyWith(newSigner, "push", device.id, device.pushKey.key),
    };
  }
  if (signedBy(oldAccount, "share", device.id, share)) {
    out.shareKey = {
      key: share.key,
      signature: await signDeviceKeyWith(newSigner, "share", device.id, share.key),
    };
  }
  return out.publicKey || out.pushKey || out.shareKey ? out : null;
}

/**
 * Another device rotated the vault key. With a share sealed to this device's share key whose key
 * the chain of rotations reaches from the account key this device already trusts, the new key
 * comes back; anything else (no share, a share the cloud sealed with a key of its own) is null,
 * and the vault locks until the password opens the new wrap.
 */
export async function openRotatedShare(input: {
  crypto: VaultCryptoBackend;
  api: CloudApi;
  vaultKey: Uint8Array;
  localEpoch: number;
  share: ShareKeyPair;
  vault: VaultInfo;
}): Promise<{ vaultKey: Uint8Array; epoch: number; previous: Uint8Array[] } | null> {
  const targetEpoch = input.vault.epoch ?? 0;
  const sealed = await input.api.getVaultShare();
  if (!sealed || sealed.epoch !== targetEpoch) return null;
  const opened = openVaultShare(input.share.secretKey, sealed.sealed);
  if (!opened || opened.epoch !== targetEpoch) return null;
  const follows = vaultKeyFollows({
    vaultKey: opened.vaultKey,
    epoch: targetEpoch,
    trustedKeyB64: exportAccountPublicKey(deriveAccountKeyPair(input.vaultKey).publicKey),
    trustedEpoch: input.localEpoch,
    rotations: input.vault.rotations,
  });
  if (!follows) return null;
  const previous = await openKeyRing(input.crypto, opened.vaultKey, input.vault.keyRing).catch(
    () => [input.vaultKey],
  );
  return { vaultKey: opened.vaultKey, epoch: targetEpoch, previous };
}
trusted-devices.ts The devices you trust, signed with the account key and each proven with your password: a rotation seals the new key to these only. packages/app/src/account/trusted-devices.ts · 454 lines · last changed 2026-10-25 in 3bcc8c991a
import nacl from "tweetnacl";
import { hmac } from "@noble/hashes/hmac.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { deriveAccountKeyPair, type AccountKeyPair } from "@shuriken/client";
import { base64ToBytes, bytesToBase64 } from "./base64";
import type { AccountSecureStore } from "./account-secure-store";

// Trusted devices (docs/security.md › Trusted devices). A vault key rotation used to seal the
// new key to every device the cloud listed whose share key the OLD account key signed. A removed
// device still holds that old key, so a cloud working with whoever kept it could list it again
// (or under a new id, its share key signed with the old key) and receive the new vault key:
// removing a device was not final.
//
// Now every device keeps a pinned set of the devices it trusts: each one's id and share key. The
// set is signed with the account key of the epoch it is for and kept, sealed, in the vault, so
// every device of the account reads the same one; a device takes the cloud's copy only when that
// signature verifies with its own account key, at its own epoch. A rotation seals the new key
// only to devices in the set (to the share key pinned there, never one the cloud lists instead)
// and writes the next epoch's set without the removed ones, signed with the new account key, in
// the same request. A set from an earlier epoch vouches for nobody but the device reading it.
// A device the cloud lists that is not in the set is shown to the person (Keep / Remove) and
// receives nothing until it is kept.
//
// The account key alone cannot carry this: the removed device holds the account key of the epoch
// being rotated from, so it can sign a set of that epoch naming a device of its own. What it does
// not hold is the password. So each entry also carries a proof, a MAC over its id and share key
// with a key derived from the password's KEK, made only where the password was just typed (sign-in,
// Unlock, Keep, a rotation). A rotation always has the password, so it checks every proof itself
// and seals only to devices whose proof holds: a set the removed device signed adds nobody.
//
// Proof v2 (passkeys open the vault, docs/security.md › Trusted devices): on an account whose
// vault opens with the unlock key U, the proof key comes from U (protocol unlock-key.ts
// trustedDeviceProofKeyV2), which every way in gives (a passkey, the password, the recovery key,
// the desktop's browser unlock), not only the password. An entry says which key made its proof
// (`proofVersion`, absent = 1); a check holds when any proof key this device has makes it. When a
// device holds both (the password opened U), the entries the password proved are proven again
// with U's key (upgradeTrustedProofs), so the account no longer needs the password to vouch.

export const TRUSTED_DEVICES_VAULT_ID = "trusted-devices";
export const TRUSTED_DEVICES_VAULT_KIND = "trusted-devices";
/** This device's own copy of the set (JSON: `{ user, set }`). */
export const ACCOUNT_TRUSTED_DEVICES_KEY = "shuriken.trusted-devices";
/** Devices removed at the device limit (no session, no vault key yet), to drop once it opens. */
export const ACCOUNT_LIMIT_REMOVED_KEY = "shuriken.limit-removed";

const CONTEXT = "shuriken/trusted-devices/v1";
const MAX_JSON_CHARS = 200_000;

/**
 * One device's standing: its share key while trusted, null once removed. Two answers for one id
 * (two devices wrote at once) are settled by `at`: the later wins, a removal on a tie.
 */
export interface TrustedDeviceEntry {
  id: string;
  key: string | null;
  at: number;
  /** The password's proof over `id` and `key` (proveTrustedDevice); null when none was made. */
  proof: string | null;
  /** Which proof key made `proof`: 1 (the password's KEK, the default) or 2 (the unlock key U). */
  proofVersion?: ProofVersion;
}

export type ProofVersion = 1 | 2;

/** A proof key and its version. A bare key is a v1 key (from the password's KEK). */
export interface TrustedProofKey {
  version: ProofVersion;
  key: Uint8Array;
}

export type AnyProofKey = Uint8Array | TrustedProofKey;

export function asProofKey(key: AnyProofKey): TrustedProofKey {
  return key instanceof Uint8Array ? { version: 1, key } : key;
}

/** Proof v2: the key U gives (protocol trustedDeviceProofKeyV2), with its version. */
export function unlockProofKey(key: Uint8Array): TrustedProofKey {
  return { version: 2, key };
}

export interface TrustedDeviceSet {
  epoch: number;
  devices: TrustedDeviceEntry[];
}

/** What the vault item holds: the set's JSON and the account key's signature over it. */
export interface SignedTrustedDevices {
  json: string;
  signature: string;
}

export function emptyTrustedDevices(epoch: number): TrustedDeviceSet {
  return { epoch, devices: [] };
}

/** Code-unit order, the same on every device (no locale). */
function compareIds(a: string, b: string): number {
  if (a === b) return 0;
  return a < b ? -1 : 1;
}

/**
 * An entry with only its own fields, in their order. A v1 entry is exactly as before proof v2, so
 * a set signed before it keeps its signature; `proofVersion` is written only for a v2 proof.
 */
function entryFields(entry: TrustedDeviceEntry): TrustedDeviceEntry {
  const out: TrustedDeviceEntry = {
    id: entry.id,
    key: entry.key,
    at: entry.at,
    proof: entry.proof,
  };
  if (entry.proofVersion === 2 && entry.proof !== null) out.proofVersion = 2;
  return out;
}

function encode(set: TrustedDeviceSet): string {
  const devices = [...set.devices].sort((a, b) => compareIds(a.id, b.id)).map(entryFields);
  return JSON.stringify({ v: 1, epoch: set.epoch, devices });
}

const PROOF_KEY_INFO = "shuriken/trusted-devices/proof-key/v1";

/** The key a device's proof is made with: from the password's KEK, never kept. */
export function trustedDevicesProofKey(kek: Uint8Array): Uint8Array {
  return hmac(sha256, kek, new TextEncoder().encode(PROOF_KEY_INFO));
}

/** The password's (or U's, v2) proof that device `id` with share key `key` is the person's. */
export function proveTrustedDevice(proofKey: AnyProofKey, id: string, key: string): string {
  return bytesToBase64(
    hmac(sha256, asProofKey(proofKey).key, new TextEncoder().encode(`${id}\n${key}`)),
  );
}

/** Whether `entry`'s proof is one any of `proofKeys` makes (whichever version it says). */
function proven(
  proofKeys: AnyProofKey | readonly AnyProofKey[],
  entry: TrustedDeviceEntry,
): boolean {
  if (entry.key === null || entry.proof === null) return false;
  const keys = Array.isArray(proofKeys) ? proofKeys : [proofKeys as AnyProofKey];
  return keys.some((key) => entry.proof === proveTrustedDevice(key, entry.id, entry.key!));
}

function message(json: string): Uint8Array {
  return new TextEncoder().encode(`${CONTEXT}\n${json}`);
}

export function sameTrustedDevices(a: TrustedDeviceSet | null, b: TrustedDeviceSet | null) {
  return a !== null && b !== null && encode(a) === encode(b);
}

/** Signs `set` with the account key of its epoch. */
export function signTrustedDevices(
  account: AccountKeyPair,
  set: TrustedDeviceSet,
): SignedTrustedDevices {
  const json = encode(set);
  return {
    json,
    signature: bytesToBase64(nacl.sign.detached(message(json), account.secretKey)),
  };
}

function isEntry(value: unknown): value is TrustedDeviceEntry {
  if (typeof value !== "object" || value === null) return false;
  const entry = value as Record<string, unknown>;
  return (
    typeof entry.id === "string" &&
    entry.id.length > 0 &&
    entry.id.length <= 64 &&
    (entry.key === null || (typeof entry.key === "string" && entry.key.length <= 64)) &&
    typeof entry.at === "number" &&
    Number.isSafeInteger(entry.at) &&
    (entry.proof === null || (typeof entry.proof === "string" && entry.proof.length <= 64)) &&
    (entry.proofVersion === undefined || entry.proofVersion === 1 || entry.proofVersion === 2)
  );
}

function parseSet(json: string): TrustedDeviceSet | null {
  try {
    const parsed = JSON.parse(json) as Record<string, unknown> | null;
    if (!parsed || parsed.v !== 1) return null;
    const { epoch, devices } = parsed;
    if (typeof epoch !== "number" || !Number.isSafeInteger(epoch) || epoch < 0) return null;
    if (!Array.isArray(devices) || !devices.every(isEntry)) return null;
    const ids = new Set(devices.map((entry) => entry.id));
    if (ids.size !== devices.length) return null;
    return {
      epoch,
      devices: devices.map(entryFields),
    };
  } catch {
    return null;
  }
}

/**
 * The set a vault item holds, only when the account key that `vaultKey` derives signed it and it
 * is for `epoch`: a set the cloud made up, or one from before a rotation (which a removed device
 * could still sign), is null.
 */
export function openTrustedDevices(
  value: unknown,
  vaultKey: Uint8Array,
  epoch: number,
): TrustedDeviceSet | null {
  if (typeof value !== "object" || value === null) return null;
  const { json, signature } = value as Partial<SignedTrustedDevices>;
  if (typeof json !== "string" || json.length > MAX_JSON_CHARS) return null;
  if (typeof signature !== "string") return null;
  let sig: Uint8Array;
  try {
    sig = base64ToBytes(signature);
  } catch {
    return null;
  }
  if (sig.byteLength !== nacl.sign.signatureLength) return null;
  const account = deriveAccountKeyPair(vaultKey);
  if (!nacl.sign.detached.verify(message(json), sig, account.publicKey)) return null;
  const set = parseSet(json);
  return set && set.epoch === epoch ? set : null;
}

function wins(a: TrustedDeviceEntry, b: TrustedDeviceEntry): boolean {
  if (a.at !== b.at) return a.at > b.at;
  if ((a.key === null) !== (b.key === null)) return a.key === null;
  if (a.key !== b.key) return (a.key ?? "") > (b.key ?? "");
  if (a.proof !== b.proof) return (a.proof ?? "") > (b.proof ?? "");
  return (a.proofVersion ?? 1) > (b.proofVersion ?? 1);
}

/** Two sets of one epoch together: every id once, its later answer kept. */
export function mergeTrustedDevices(
  a: TrustedDeviceSet,
  b: TrustedDeviceSet | null,
): TrustedDeviceSet {
  if (!b || b.epoch !== a.epoch) return a;
  const byId = new Map(a.devices.map((entry) => [entry.id, entry] as const));
  for (const entry of b.devices) {
    const held = byId.get(entry.id);
    if (!held || wins(entry, held)) byId.set(entry.id, entry);
  }
  return { epoch: a.epoch, devices: [...byId.values()] };
}

/**
 * The trusted devices' share keys, by device id: with `proofKey` (a rotation, which has the
 * password) only the ones whose proof holds; without it, every one the set says was proven.
 */
export function liveTrustedDevices(
  set: TrustedDeviceSet | null,
  proofKey?: AnyProofKey | readonly AnyProofKey[],
): Map<string, string> {
  const out = new Map<string, string>();
  for (const entry of set?.devices ?? []) {
    if (entry.key === null || entry.proof === null) continue;
    if (proofKey && !proven(proofKey, entry)) continue;
    out.set(entry.id, entry.key);
  }
  return out;
}

/**
 * `set` with `id` holding `key` (null: removed) from now, with `proof` when one was made. The same
 * set when it already does; an entry with the same key keeps the proof it has when none is given.
 */
export function withTrustedDevice(
  set: TrustedDeviceSet,
  id: string,
  key: string | null,
  now: number,
  proof?: string | null,
  proofVersion: ProofVersion = 1,
): TrustedDeviceSet {
  const held = set.devices.find((entry) => entry.id === id);
  if (held && held.key === key && (proof === undefined || proof === null || held.proof === proof)) {
    return set;
  }
  const at = Math.max(Math.floor(now), (held?.at ?? 0) + 1);
  const kept = held && held.key === key ? held : null;
  const nextProof = key === null ? null : (proof ?? kept?.proof ?? null);
  const version = proof ? proofVersion : (kept?.proofVersion ?? 1);
  return {
    epoch: set.epoch,
    devices: [
      ...set.devices.filter((entry) => entry.id !== id),
      {
        id,
        key,
        at,
        proof: nextProof,
        ...(nextProof && version === 2 ? { proofVersion: 2 as const } : {}),
      },
    ],
  };
}

/**
 * A password change (VAULT-7): every entry proven with the old password's KEK proven again with
 * the new one. An entry the old one does not prove keeps no proof (Keep asks for it again).
 * An entry given a new proof moves one past its `at`, so a copy from before (another device's,
 * same epoch) never wins a later merge and puts back a proof only the old key makes.
 */
export function reproveTrustedDevices(
  set: TrustedDeviceSet,
  oldProofKey: AnyProofKey | null,
  newProofKey: AnyProofKey,
): TrustedDeviceSet {
  const next = asProofKey(newProofKey);
  return {
    epoch: set.epoch,
    devices: set.devices.map((entry) => {
      if (entry.key === null) return entry;
      if (proven(next, entry)) return withVersion(entry, entry.proof, next.version);
      if (oldProofKey && proven(oldProofKey, entry)) {
        return reproven(entry, proveTrustedDevice(next, entry.id, entry.key), next.version);
      }
      return withVersion(entry, null, next.version);
    }),
  };
}

function withVersion(
  entry: TrustedDeviceEntry,
  proof: string | null,
  version: ProofVersion,
): TrustedDeviceEntry {
  const { proofVersion: _old, ...rest } = entry;
  return { ...rest, proof, ...(proof && version === 2 ? { proofVersion: 2 as const } : {}) };
}

/**
 * `entry` with a proof made again: its `at` one later, so it beats the same entry with the old
 * proof in every merge (wins() would otherwise settle the tie on the proofs' text, either way).
 */
function reproven(
  entry: TrustedDeviceEntry,
  proof: string,
  version: ProofVersion,
): TrustedDeviceEntry {
  const next = withVersion(entry, proof, version);
  return proof === entry.proof ? next : { ...next, at: entry.at + 1 };
}

/**
 * Proof v2: an unlock-key account's entries that the password's KEK (`v1`) proved, proven again
 * with U's key (`v2`), when a device holds both (the password opened U, or the account just moved
 * to the unlock key). Every other entry stays as it is: one neither key proves is left for Keep,
 * never cleared here. A moved entry's `at` goes one later (reproven). The same set when nothing
 * changed.
 */
export function upgradeTrustedProofs(
  set: TrustedDeviceSet,
  v1: AnyProofKey,
  v2: TrustedProofKey,
): TrustedDeviceSet {
  let changed = false;
  const devices = set.devices.map((entry) => {
    if (entry.key === null || entry.proofVersion === 2) return entry;
    if (!proven(v1, entry)) return entry;
    changed = true;
    return reproven(entry, proveTrustedDevice(v2, entry.id, entry.key), 2);
  });
  return changed ? { epoch: set.epoch, devices } : set;
}

/**
 * The set this device holds at `epoch`: its own copy and the cloud's (both only when they are for
 * `epoch`) together, with this device in it under its share key, and the devices removed at the
 * device limit out. A copy from an earlier epoch is left behind: after a rotation, only the
 * rotating device's set (or the people's own Keep) vouches for anyone else.
 */
export function reconcileTrustedDevices(input: {
  epoch: number;
  local: TrustedDeviceSet | null;
  cloud: TrustedDeviceSet | null;
  self: { id: string; key: string; proof?: string | null; proofVersion?: ProofVersion } | null;
  removed?: readonly string[];
  now: number;
}): TrustedDeviceSet {
  let set = emptyTrustedDevices(input.epoch);
  if (input.local?.epoch === input.epoch) set = mergeTrustedDevices(set, input.local);
  if (input.cloud?.epoch === input.epoch) set = mergeTrustedDevices(set, input.cloud);
  for (const id of input.removed ?? []) {
    if (id !== input.self?.id && liveTrustedDevices(set).has(id)) {
      set = withTrustedDevice(set, id, null, input.now);
    }
  }
  if (input.self) {
    set = withTrustedDevice(
      set,
      input.self.id,
      input.self.key,
      input.now,
      input.self.proof,
      input.self.proofVersion,
    );
  }
  return set;
}

/**
 * The next epoch's set a rotation writes: the devices whose proof the password (`proofKey`)
 * checks, but `removed`, as they were. One with no proof, or one that does not hold, is left out.
 */
export function carryTrustedDevices(
  set: TrustedDeviceSet,
  removed: ReadonlySet<string>,
  nextEpoch: number,
  proofKey: AnyProofKey | readonly AnyProofKey[],
): TrustedDeviceSet {
  return {
    epoch: nextEpoch,
    devices: set.devices.filter((entry) => !removed.has(entry.id) && proven(proofKey, entry)),
  };
}

/** This device's own copy for `userId`, or null (none, another account's, or damaged). */
export async function loadLocalTrustedDevices(
  store: Pick<AccountSecureStore, "get">,
  userId: string,
): Promise<TrustedDeviceSet | null> {
  const raw = await store.get(ACCOUNT_TRUSTED_DEVICES_KEY).catch(() => null);
  if (!raw) return null;
  try {
    const parsed = JSON.parse(raw) as { user?: unknown; set?: unknown } | null;
    if (!parsed || parsed.user !== userId || typeof parsed.set !== "string") return null;
    return parseSet(parsed.set);
  } catch {
    return null;
  }
}

export async function saveLocalTrustedDevices(
  store: Pick<AccountSecureStore, "set">,
  userId: string,
  set: TrustedDeviceSet,
): Promise<void> {
  await store
    .set(ACCOUNT_TRUSTED_DEVICES_KEY, JSON.stringify({ user: userId, set: encode(set) }))
    .catch(() => undefined);
}

export async function loadLimitRemoved(store: Pick<AccountSecureStore, "get">): Promise<string[]> {
  try {
    const parsed = JSON.parse((await store.get(ACCOUNT_LIMIT_REMOVED_KEY)) ?? "[]") as unknown;
    return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string") : [];
  } catch {
    return [];
  }
}

Signed releases

release-signing.ts Checking a release's Ed25519 signature before a server installs it or an app writes an install command. packages/relay/src/release-signing.ts · 89 lines · last changed 2026-10-22 in 8344f2ee0e
/**
 * Round 36 S3 (docs/security.md › Signed releases): every release carries `shuriken-release.json`
 * — the SHA-256 of its installers and worker bundles — and `shuriken-release.json.sig`, Ed25519
 * over a context line and those bytes, made with a key the cloud never holds
 * (scripts/release-signing.mjs). The apps check it before they write a server's install command
 * (the installer's hash goes in the command), and a worker checks it before it installs an update,
 * so a cloud running modified code cannot hand a server code of its own.
 *
 * Changing the key needs a release: apps and workers that pin the old one refuse every release
 * signed with the new one. The private half is the owner's, encrypted under a passphrase outside
 * every checkout (scripts/signing-keys.mjs), and the release workflow's SHURIKEN_RELEASE_SIGNING_KEY
 * secret.
 */

import nacl from "tweetnacl";
import { toByteArray } from "base64-js";
import { concatBytes, utf8 } from "./crypto.js";

/** Raw 32-byte Ed25519 public key, base64. */
export const RELEASE_SIGNING_PUBLIC_KEY = "/LzbAJl4GA2ZmtwCZGat01FduGFgNZZXvm1bj+2nCVA=";

export const RELEASE_SIGNING_CONTEXT = "shuriken/release/v1\n";
export const RELEASE_MANIFEST_NAME = "shuriken-release.json";
export const RELEASE_SIGNATURE_NAME = `${RELEASE_MANIFEST_NAME}.sig`;

export interface ReleaseManifest {
  v: 1;
  version: string;
  /** File name → lowercase SHA-256 hex. */
  files: Record<string, string>;
}

function decodeKey(base64: string): Uint8Array | null {
  try {
    const bytes = toByteArray(base64.trim());
    return bytes.length === nacl.sign.publicKeyLength ? bytes : null;
  } catch {
    return null;
  }
}

function parseManifest(text: string): ReleaseManifest | null {
  let parsed: unknown;
  try {
    parsed = JSON.parse(text);
  } catch {
    return null;
  }
  if (typeof parsed !== "object" || parsed === null) return null;
  const record = parsed as Record<string, unknown>;
  if (record.v !== 1 || typeof record.version !== "string" || !record.version) return null;
  if (typeof record.files !== "object" || record.files === null) return null;
  const files: Record<string, string> = {};
  for (const [name, sha] of Object.entries(record.files as Record<string, unknown>)) {
    if (typeof sha !== "string" || !/^[0-9a-f]{64}$/.test(sha)) return null;
    files[name] = sha;
  }
  return { v: 1, version: record.version, files };
}

/**
 * The manifest when `signatureB64` is the pinned key's signature over exactly `manifestText`;
 * null for anything else (another key, a changed byte, a malformed manifest).
 */
export function verifyReleaseManifest(input: {
  manifest: string;
  signature: string;
  publicKey?: string;
}): ReleaseManifest | null {
  const key = decodeKey(input.publicKey ?? RELEASE_SIGNING_PUBLIC_KEY);
  if (!key) return null;
  let signature: Uint8Array;
  try {
    signature = toByteArray(input.signature.trim());
  } catch {
    return null;
  }
  if (signature.length !== nacl.sign.signatureLength) return null;
  const message = concatBytes(utf8(RELEASE_SIGNING_CONTEXT), utf8(input.manifest));
  if (!nacl.sign.detached.verify(message, signature, key)) return null;
  return parseManifest(input.manifest);
}

/** The SHA-256 the signed manifest gives `name`, or null when it does not list it. */
export function signedSha256(manifest: ReleaseManifest, name: string): string | null {
  return Object.prototype.hasOwnProperty.call(manifest.files, name)
    ? (manifest.files[name] ?? null)
    : null;
}

This code is shown so you can check it. We have not chosen a licence for it yet, so this page gives you none: ask us before you copy or reuse it. Files marked Apache-2.0 began in an open-source project under the Apache License 2.0 and keep that licence.

Questions about the encryption?

Read the security overview, or ask us directly.