/** * Pair protocol v6 — trust bootstrap for new devices. * * Threat model: the pairing URL (QR or curl token) is assumed to travel over * a partially-trusted channel. Passive eavesdroppers must not learn the * cluster secret; active MitM must be detectable. v6 builds on v5 by * replacing the single `{host, proxyPort, relayUrl}` triple with an * ordered list of endpoints (direct + relay), so the joining device can * happy-eyeballs probe whichever path actually reaches the issuer. * * Keys involved on the issuer (A) side: * - ed25519 identity keypair — signs the transcript (prevents MitM). * - X25519 static kex keypair — performs ECDH with the redeemer's * ephemeral pubkey (derives the AEAD key that encrypts the secret). * * On the redeemer (D) side: * - one-shot X25519 ephemeral keypair per redeem attempt. * * Transcript binding covers every material in the exchange — token, * both static pubkeys, ephemeral pubkey, ciphertext/nonce/tag, nodeId, * listenPort, and the full endpoint list — so an attacker swapping any * field (including injecting a malicious endpoint) invalidates the * ed25519 signature. * * AEAD (AES-256-GCM) is reused from crypto.ts to keep platform parity * with the iOS client (CryptoKit) and avoid introducing ChaCha20. */ import { type X25519KeyPair } from "./crypto.ts"; export declare const PAIR_PROTOCOL_VERSION = 6; /** * A single dialable endpoint advertised in the pair offer. * * `direct` — full base URL the joiner POSTs `/api/pair/redeem` to. * Covers LAN IPs (`http://192.168.x.x:23513`) and public * tunnel URLs (`https://x.trycloudflare.com`). * `relay` — full WebSocket base URL of a Feno relay. The joiner dials * `${url}/v1/room/` and sends a * framed `pair_redeem` message over the pair-room. */ export interface Endpoint { kind: "direct" | "relay"; url: string; } /** Parsed pair offer (from QR URL). Version is always PAIR_PROTOCOL_VERSION. */ export interface PairOffer { version: typeof PAIR_PROTOCOL_VERSION; token: string; signingPublicKey: Buffer; kexPublicKey: Buffer; endpoints: Endpoint[]; } /** Request body D sends to POST /api/pair/redeem. */ export interface RedeemRequest { token: string; ephemeralKexPubkey: Buffer; nodeId?: string; publicKey?: string; name?: string; } /** Sealed response A sends back. All Buffer fields are raw bytes. */ export interface SealedRedeemResponse { version: typeof PAIR_PROTOCOL_VERSION; nodeId: string; listenPort: number; signingPublicKey: Buffer; kexPublicKey: Buffer; ciphertext: Buffer; nonce: Buffer; tag: Buffer; sig: Buffer; endpoints: Endpoint[]; } /** What D gets after successfully opening the sealed response. */ export interface RedeemResult { secret: string; nodeId: string; listenPort: number; signingPublicKey: Buffer; endpoints: Endpoint[]; } /** * Build the feno://pair?d=... URL. Only v6 is emitted — callers must * always supply both signing and kex public keys plus at least one * endpoint. */ export declare function buildOfferUrl(params: { token: string; signingPublicKey: Buffer; kexPublicKey: Buffer; endpoints: Endpoint[]; }): { url: string; raw: Record; }; /** Parse feno://pair?d=. Rejects older protocol versions. */ export declare function parseOfferUrl(input: string): PairOffer; /** Fresh X25519 ephemeral keypair for a single redeem attempt. */ export declare function generateEphemeralKexKeyPair(): X25519KeyPair; /** * Deterministic 6-char hex fingerprint derived from the request's * ephemeral pubkey bound to the offer's static identities. Both sides * can compute it independently without exchanging extra data — the * human compares the code shown on D's screen with the code shown in * the approval prompt on A. * * 3 bytes = 24 bits ≈ 16M combinations — enough for eyeball compare, * not enough for offline brute force of the pubkey (which is 256 bits). */ export declare function deriveFingerprint(input: { token: string; signingPublicKey: Buffer; kexPublicKey: Buffer; ephemeralPublicKey: Buffer; }): string; /** * A accepts a RedeemRequest, encrypts the secret under an AEAD key * derived from ECDH(A_static_priv, D_ephemeral_pub), and signs the * full transcript with A's ed25519 identity key. */ export declare function sealRedeem(params: { secret: string; token: string; ephemeralKexPubkey: Buffer; staticKexKeyPair: X25519KeyPair; /** Sign arbitrary data with the node's ed25519 identity key. */ sign: (data: Buffer) => Buffer; signingPublicKey: Buffer; nodeId: string; listenPort: number; endpoints: Endpoint[]; }): SealedRedeemResponse; /** * D verifies the sealed response and decrypts the secret. Throws on any * tamper or key mismatch. MUST be called with the exact offer D used to * initiate the redeem (so MitM swapping pubkeys is caught). */ export declare function openRedeem(params: { offer: PairOffer; ephemeralKexKeyPair: X25519KeyPair; sealed: SealedRedeemResponse; }): RedeemResult; /** Serialize a SealedRedeemResponse to a JSON-safe object (base64url buffers). */ export declare function serializeSealed(sealed: SealedRedeemResponse): Record; /** Parse a wire-format sealed response back into a SealedRedeemResponse. */ export declare function deserializeSealed(raw: unknown): SealedRedeemResponse;