// Device manifest — v2 brief §4.3. // // A device manifest is a list of paired-device entries, signed by the // issuer's random per-installation Ed25519 signing key (the local device // identity in src/background/device-identity.ts). // Manifests are exchanged after the entry sync completes during a normal // `SyncSession`, encrypted under reconnect Noise KK along with everything else. // // Purpose: propagate trust between devices without repeating first-pair // confirmation. Any already-admitted, Noise-authenticated mesh peer may issue // a manifest. That makes membership a convergent pool instead of a graph whose // updates still depend on the device that performed the original pairing. // The receiver verifies the signature against that issuer's known Ed25519 // public key. A v3 entry that carries a valid relay route is auto-admitted as // same-vault "vouched" state and may propagate that signed graph onward. // See docs/mesh-vouching-authority.md for the full authority trace. // // Security properties: // - Authenticity: Ed25519 signature over a canonicalised JSON body. // Receiver rejects any manifest whose signature does not verify // against the issuer's stored signing pubkey. // - Freshness: `issuedAt` must be within DEVICE_MANIFEST_FRESHNESS_MS // of the receiver's clock; older manifests are silently discarded. // - Vault binding: the manifest is exchanged over Noise KK which has already // proven both locally-paired vault-derived static keys. import { DEVICE_MANIFEST_FRESHNESS_MS, DEVICE_MANIFEST_VERSION, DEVICE_REVOCATION_RECORD_VERSION, MAX_VOUCH_DEPTH, type DeviceManifestBody, type DeviceRevocation, type ManifestDeviceEntry, type SignedDeviceManifest, } from "./sync-types"; import { signEd25519, verifyEd25519 } from "./sync-identity"; import { normalizeRelayUrl } from "./rendezvous"; import { capDeviceName } from "./sync-limits"; export class DeviceManifestError extends Error { readonly kind: | "version" | "freshness" | "signature" | "issuer_mismatch" | "malformed"; constructor(kind: DeviceManifestError["kind"], message: string) { super(message); this.kind = kind; } } /** * Build + sign a device manifest with `issuedAt = now`. * * The body is canonicalised before signing using a stable JSON encoder * (keys sorted, no insignificant whitespace) so any two implementations * of this codec produce byte-identical signing input from equal inputs. */ export async function buildSignedDeviceManifest( issuerDeviceId: string, devices: ManifestDeviceEntry[], signingPrivateKey: CryptoKey, options: { now?: number; revoked?: DeviceRevocation[] } = {}, ): Promise { if (!issuerDeviceId) throw new Error("issuerDeviceId is required"); const body: DeviceManifestBody = { manifestVersion: DEVICE_MANIFEST_VERSION, issuerDeviceId, issuedAt: options.now ?? Date.now(), devices: devices.map(normaliseEntry), revoked: (options.revoked ?? []).map(normaliseRevocation), }; const canonical = canonicalise(body); const signature = await signEd25519( signingPrivateKey, new TextEncoder().encode(canonical), ); return { body, signatureHex: bytesToHex(signature) }; } /** Entry counts and identifier lengths mirror Android's manifest contract. */ const MAX_MANIFEST_DEVICES = 256; const MAX_MANIFEST_REVOCATIONS = 500; const MAX_MANIFEST_STRING = 256; // Three UTF-8 bytes per UTF-16 code unit preserves the existing identifier limit. const MAX_MANIFEST_IDENTIFIER_BYTES = MAX_MANIFEST_STRING * 3; const MAX_MANIFEST_DEVICE_NAME_BYTES = 256; const MAX_MANIFEST_RELAY_URL_BYTES = 2_048; const MAX_REVOCATION_SIGNATURE_BYTES = 128; const UTF8_ENCODER = new TextEncoder(); const MANIFEST_FIELDS = new Set(["body", "signatureHex"]); const MANIFEST_BODY_FIELDS = new Set([ "manifestVersion", "issuerDeviceId", "issuedAt", "devices", "revoked", ]); const MANIFEST_DEVICE_FIELDS = new Set([ "deviceId", "deviceName", "deviceType", "longTermPublicKeyHex", "signingPublicKeyHex", "relayUrl", "wsPort", "pairedAt", "vouchDepth", ]); const MANIFEST_REVOCATION_FIELDS = new Set([ "deviceId", "longTermPublicKeyHex", "revokedAt", "revokedByDeviceId", "recordVersion", "signatureHex", ]); /** Canonical fields authenticated by an issuer for one revocation record. */ export function canonicalDeviceRevocation(record: DeviceRevocation): string { return canonicalise({ deviceId: record.deviceId, longTermPublicKeyHex: record.longTermPublicKeyHex.toLowerCase(), recordVersion: record.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION, revokedAt: record.revokedAt, revokedByDeviceId: record.revokedByDeviceId, }); } export async function signDeviceRevocation( record: DeviceRevocation, signingPrivateKey: CryptoKey, ): Promise { const unsigned: DeviceRevocation = { ...record, longTermPublicKeyHex: record.longTermPublicKeyHex.toLowerCase(), recordVersion: record.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION, }; const signature = await signEd25519( signingPrivateKey, new TextEncoder().encode(canonicalDeviceRevocation(unsigned)), ); return { ...unsigned, signatureHex: bytesToHex(signature) }; } export async function verifyDeviceRevocation( record: DeviceRevocation, issuerSigningPubkey: Uint8Array, ): Promise { if (!record.signatureHex || !/^[0-9a-f]{128}$/i.test(record.signatureHex)) return false; try { return await verifyEd25519( issuerSigningPubkey, new TextEncoder().encode(canonicalDeviceRevocation(record)), hexToBytes(record.signatureHex), ); } catch { return false; } } function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function assertAllowedFields( value: Record, allowed: ReadonlySet, label: string, ): void { for (const field in value) { if (Object.hasOwn(value, field) && !allowed.has(field)) { throw new DeviceManifestError("malformed", `${label}.${field} is not allowed`); } } } function fitsUtf8Budget(value: string, maxBytes: number): boolean { const buffer = new Uint8Array(maxBytes + 1); const { read, written } = UTF8_ENCODER.encodeInto(value, buffer); return read === value.length && written <= maxBytes; } function isBoundedIdentifier(value: unknown): value is string { return ( typeof value === "string" && value.length > 0 && value.length <= MAX_MANIFEST_STRING && fitsUtf8Budget(value, MAX_MANIFEST_IDENTIFIER_BYTES) ); } function preflightDeviceManifest( manifest: SignedDeviceManifest, now: number, freshness: number, ): void { if (!isRecord(manifest)) { throw new DeviceManifestError("malformed", "manifest is not an object"); } assertAllowedFields(manifest, MANIFEST_FIELDS, "manifest"); if (!isRecord(manifest.body) || typeof manifest.signatureHex !== "string") { throw new DeviceManifestError("malformed", "manifest missing body or signature"); } if (manifest.signatureHex.length !== 128 || !/^[0-9a-f]+$/i.test(manifest.signatureHex)) { throw new DeviceManifestError("signature", "signature must be 64-byte hex"); } assertAllowedFields(manifest.body, MANIFEST_BODY_FIELDS, "manifest.body"); if (manifest.body.manifestVersion !== DEVICE_MANIFEST_VERSION) { throw new DeviceManifestError( "version", `unsupported manifest version ${manifest.body.manifestVersion}`, ); } if ( typeof manifest.body.issuedAt !== "number" || !Number.isFinite(manifest.body.issuedAt) || Math.abs(now - manifest.body.issuedAt) > freshness ) { throw new DeviceManifestError( "freshness", `manifest issuedAt ${manifest.body.issuedAt} outside ±${freshness}ms window`, ); } if (!isBoundedIdentifier(manifest.body.issuerDeviceId)) { throw new DeviceManifestError("malformed", "issuerDeviceId invalid"); } if (!Array.isArray(manifest.body.devices)) { throw new DeviceManifestError("malformed", "devices is not an array"); } if (manifest.body.devices.length > MAX_MANIFEST_DEVICES) { throw new DeviceManifestError("malformed", "devices exceeds MAX_MANIFEST_DEVICES"); } if (!Array.isArray(manifest.body.revoked)) { throw new DeviceManifestError("malformed", "revoked is not an array"); } if (manifest.body.revoked.length > MAX_MANIFEST_REVOCATIONS) { throw new DeviceManifestError("malformed", "revoked exceeds MAX_MANIFEST_REVOCATIONS"); } for (const device of manifest.body.devices) { if (!isRecord(device)) { throw new DeviceManifestError("malformed", "device is not an object"); } assertAllowedFields(device, MANIFEST_DEVICE_FIELDS, "device"); if (!isBoundedIdentifier(device.deviceId)) { throw new DeviceManifestError("malformed", "device.deviceId missing"); } if ( typeof device.deviceName !== "string" || device.deviceName.length === 0 || !fitsUtf8Budget(device.deviceName, MAX_MANIFEST_DEVICE_NAME_BYTES) ) { throw new DeviceManifestError("malformed", "device.deviceName invalid"); } if ( device.deviceType !== "extension" && device.deviceType !== "android" && device.deviceType !== "ios" ) { throw new DeviceManifestError("malformed", "device.deviceType invalid"); } if ( typeof device.longTermPublicKeyHex !== "string" || device.longTermPublicKeyHex.length !== 64 ) { throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid"); } if ( typeof device.signingPublicKeyHex !== "string" || device.signingPublicKeyHex.length !== 64 ) { throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid"); } if ( device.vouchDepth != null && ( !Number.isInteger(device.vouchDepth) || (device.vouchDepth as number) < 0 || (device.vouchDepth as number) > MAX_VOUCH_DEPTH ) ) { throw new DeviceManifestError("malformed", "device.vouchDepth invalid"); } if ( typeof device.pairedAt !== "number" || !Number.isFinite(device.pairedAt) || device.pairedAt < 0 ) { throw new DeviceManifestError("malformed", "device.pairedAt invalid"); } if ( device.wsPort != null && ( typeof device.wsPort !== "number" || !Number.isInteger(device.wsPort) || device.wsPort < 1 || device.wsPort > 65_535 ) ) { throw new DeviceManifestError("malformed", "device.wsPort invalid"); } if (device.relayUrl != null) { if ( typeof device.relayUrl !== "string" || !fitsUtf8Budget(device.relayUrl, MAX_MANIFEST_RELAY_URL_BYTES) ) { throw new DeviceManifestError("malformed", "device.relayUrl invalid"); } try { normalizeRelayUrl(device.relayUrl); } catch { throw new DeviceManifestError("malformed", "device.relayUrl invalid"); } } } for (const revocation of manifest.body.revoked) { if (!isRecord(revocation)) { throw new DeviceManifestError("malformed", "revocation is not an object"); } assertAllowedFields(revocation, MANIFEST_REVOCATION_FIELDS, "revocation"); if (!isBoundedIdentifier(revocation.deviceId)) { throw new DeviceManifestError("malformed", "revocation.deviceId invalid"); } if ( typeof revocation.longTermPublicKeyHex !== "string" || !/^[0-9a-fA-F]{64}$/.test(revocation.longTermPublicKeyHex) ) { throw new DeviceManifestError( "malformed", "revocation.longTermPublicKeyHex invalid", ); } if (typeof revocation.revokedAt !== "number" || !Number.isFinite(revocation.revokedAt)) { throw new DeviceManifestError("malformed", "revocation.revokedAt invalid"); } if (!isBoundedIdentifier(revocation.revokedByDeviceId)) { throw new DeviceManifestError("malformed", "revocation.revokedByDeviceId invalid"); } if ( revocation.recordVersion != null && revocation.recordVersion !== DEVICE_REVOCATION_RECORD_VERSION ) { throw new DeviceManifestError( "malformed", `revocation.recordVersion unsupported (${revocation.recordVersion})`, ); } if ( revocation.signatureHex != null && ( typeof revocation.signatureHex !== "string" || !fitsUtf8Budget(revocation.signatureHex, MAX_REVOCATION_SIGNATURE_BYTES) ) ) { throw new DeviceManifestError("malformed", "revocation.signatureHex invalid"); } } } /** * Verify a manifest's signature, version, and freshness window. Throws on * any failure; returns the body on success. The caller is responsible for * looking up the issuer's signing pubkey from the paired-device registry * and passing it in — that lookup is what enforces "manifest from a known * device" (§4.3 final paragraph). */ export async function verifyDeviceManifest( manifest: SignedDeviceManifest, issuerSigningPubkey: Uint8Array, options: { now?: number; freshnessMs?: number } = {}, ): Promise { const now = options.now ?? Date.now(); const freshness = options.freshnessMs ?? DEVICE_MANIFEST_FRESHNESS_MS; preflightDeviceManifest(manifest, now, freshness); const signature = hexToBytes(manifest.signatureHex); const canonical = canonicalise(manifest.body); const ok = await verifyEd25519( issuerSigningPubkey, new TextEncoder().encode(canonical), signature, ); if (!ok) throw new DeviceManifestError("signature", "Ed25519 signature did not verify"); return manifest.body; } function normaliseRevocation(r: DeviceRevocation): DeviceRevocation { const normalised: DeviceRevocation = { deviceId: r.deviceId, longTermPublicKeyHex: r.longTermPublicKeyHex.toLowerCase(), revokedAt: r.revokedAt, revokedByDeviceId: r.revokedByDeviceId, recordVersion: r.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION, }; if (r.signatureHex != null) normalised.signatureHex = r.signatureHex.toLowerCase(); return normalised; } function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry { const normalised: ManifestDeviceEntry = { deviceId: d.deviceId, deviceName: capDeviceName(d.deviceName), deviceType: d.deviceType, longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(), signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(), pairedAt: d.pairedAt, vouchDepth: d.vouchDepth ?? 0, }; if (d.wsPort != null) normalised.wsPort = d.wsPort; if (d.relayUrl != null) normalised.relayUrl = normalizeRelayUrl(d.relayUrl); return normalised; } /** * Canonical JSON serialiser. Sorts object keys, no trailing whitespace, * stable number formatting. The serialiser intentionally only handles the * narrow set of shapes that occur inside a `DeviceManifestBody` — extending * it later means extending these branches in lockstep with the schema. */ export function canonicalise(value: unknown): string { if (value === null) return "null"; if (typeof value === "number") { if (!Number.isFinite(value)) throw new Error("non-finite number"); return Number.isInteger(value) ? value.toString() : value.toString(); } if (typeof value === "string") return JSON.stringify(value); if (typeof value === "boolean") return value ? "true" : "false"; if (Array.isArray(value)) { return "[" + value.map((v) => canonicalise(v)).join(",") + "]"; } if (typeof value === "object") { const obj = value as Record; const keys = Object.keys(obj).sort(); // ponytail: skip null values so canonical bytes match Android's // manifestEntryMap, which omits wsPort and relayUrl when null. const present = keys.filter((k) => obj[k] !== null); return ( "{" + present.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") + "}" ); } throw new Error(`cannot canonicalise ${typeof value}`); } function bytesToHex(b: Uint8Array): string { let s = ""; for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0"); return s; } function hexToBytes(hex: string): Uint8Array { if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex"); const out = new Uint8Array(hex.length / 2); for (let i = 0; i < out.length; i++) { const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16); if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex"); out[i] = b; } return out; }