123456789101112131415161718192021222324252627import {28 DEVICE_MANIFEST_FRESHNESS_MS,29 DEVICE_MANIFEST_VERSION,30 DEVICE_REVOCATION_RECORD_VERSION,31 MAX_VOUCH_DEPTH,32 type DeviceManifestBody,33 type DeviceRevocation,34 type ManifestDeviceEntry,35 type SignedDeviceManifest,36} from "./sync-types";37import { signEd25519, verifyEd25519 } from "./sync-identity";38import { normalizeRelayUrl } from "./rendezvous";39import { capDeviceName } from "./sync-limits";4041export class DeviceManifestError extends Error {42 readonly kind:43 | "version"44 | "freshness"45 | "signature"46 | "issuer_mismatch"47 | "malformed";48 constructor(kind: DeviceManifestError["kind"], message: string) {49 super(message);50 this.kind = kind;51 }52}535455565758596061export async function buildSignedDeviceManifest(62 issuerDeviceId: string,63 devices: ManifestDeviceEntry[],64 signingPrivateKey: CryptoKey,65 options: { now?: number; revoked?: DeviceRevocation[] } = {},66): Promise<SignedDeviceManifest> {67 if (!issuerDeviceId) throw new Error("issuerDeviceId is required");68 const body: DeviceManifestBody = {69 manifestVersion: DEVICE_MANIFEST_VERSION,70 issuerDeviceId,71 issuedAt: options.now ?? Date.now(),72 devices: devices.map(normaliseEntry),73 revoked: (options.revoked ?? []).map(normaliseRevocation),74 };75 const canonical = canonicalise(body);76 const signature = await signEd25519(77 signingPrivateKey,78 new TextEncoder().encode(canonical),79 );80 return { body, signatureHex: bytesToHex(signature) };81}8283const MAX_MANIFEST_DEVICES = 256;84const MAX_MANIFEST_REVOCATIONS = 500;85const MAX_MANIFEST_STRING = 256;8687const MAX_MANIFEST_IDENTIFIER_BYTES = MAX_MANIFEST_STRING * 3;88const MAX_MANIFEST_DEVICE_NAME_BYTES = 256;89const MAX_MANIFEST_RELAY_URL_BYTES = 2_048;90const MAX_REVOCATION_SIGNATURE_BYTES = 128;91const UTF8_ENCODER = new TextEncoder();92const MANIFEST_FIELDS = new Set(["body", "signatureHex"]);93const MANIFEST_BODY_FIELDS = new Set([94 "manifestVersion",95 "issuerDeviceId",96 "issuedAt",97 "devices",98 "revoked",99]);100const MANIFEST_DEVICE_FIELDS = new Set([101 "deviceId",102 "deviceName",103 "deviceType",104 "longTermPublicKeyHex",105 "signingPublicKeyHex",106 "relayUrl",107 "wsPort",108 "pairedAt",109 "vouchDepth",110]);111const MANIFEST_REVOCATION_FIELDS = new Set([112 "deviceId",113 "longTermPublicKeyHex",114 "revokedAt",115 "revokedByDeviceId",116 "recordVersion",117 "signatureHex",118]);119120121export function canonicalDeviceRevocation(record: DeviceRevocation): string {122 return canonicalise({123 deviceId: record.deviceId,124 longTermPublicKeyHex: record.longTermPublicKeyHex.toLowerCase(),125 recordVersion: record.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,126 revokedAt: record.revokedAt,127 revokedByDeviceId: record.revokedByDeviceId,128 });129}130131export async function signDeviceRevocation(132 record: DeviceRevocation,133 signingPrivateKey: CryptoKey,134): Promise<DeviceRevocation> {135 const unsigned: DeviceRevocation = {136 ...record,137 longTermPublicKeyHex: record.longTermPublicKeyHex.toLowerCase(),138 recordVersion: record.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,139 };140 const signature = await signEd25519(141 signingPrivateKey,142 new TextEncoder().encode(canonicalDeviceRevocation(unsigned)),143 );144 return { ...unsigned, signatureHex: bytesToHex(signature) };145}146147export async function verifyDeviceRevocation(148 record: DeviceRevocation,149 issuerSigningPubkey: Uint8Array,150): Promise<boolean> {151 if (!record.signatureHex || !/^[0-9a-f]{128}$/i.test(record.signatureHex)) return false;152 try {153 return await verifyEd25519(154 issuerSigningPubkey,155 new TextEncoder().encode(canonicalDeviceRevocation(record)),156 hexToBytes(record.signatureHex),157 );158 } catch {159 return false;160 }161}162163function isRecord(value: unknown): value is Record<string, unknown> {164 return value !== null && typeof value === "object" && !Array.isArray(value);165}166167function assertAllowedFields(168 value: Record<string, unknown>,169 allowed: ReadonlySet<string>,170 label: string,171): void {172 for (const field in value) {173 if (Object.hasOwn(value, field) && !allowed.has(field)) {174 throw new DeviceManifestError("malformed", `${label}.${field} is not allowed`);175 }176 }177}178179function fitsUtf8Budget(value: string, maxBytes: number): boolean {180 const buffer = new Uint8Array(maxBytes + 1);181 const { read, written } = UTF8_ENCODER.encodeInto(value, buffer);182 return read === value.length && written <= maxBytes;183}184185function isBoundedIdentifier(value: unknown): value is string {186 return (187 typeof value === "string" &&188 value.length > 0 &&189 value.length <= MAX_MANIFEST_STRING &&190 fitsUtf8Budget(value, MAX_MANIFEST_IDENTIFIER_BYTES)191 );192}193194function preflightDeviceManifest(195 manifest: SignedDeviceManifest,196 now: number,197 freshness: number,198): void {199 if (!isRecord(manifest)) {200 throw new DeviceManifestError("malformed", "manifest is not an object");201 }202 assertAllowedFields(manifest, MANIFEST_FIELDS, "manifest");203 if (!isRecord(manifest.body) || typeof manifest.signatureHex !== "string") {204 throw new DeviceManifestError("malformed", "manifest missing body or signature");205 }206 if (manifest.signatureHex.length !== 128 || !/^[0-9a-f]+$/i.test(manifest.signatureHex)) {207 throw new DeviceManifestError("signature", "signature must be 64-byte hex");208 }209 assertAllowedFields(manifest.body, MANIFEST_BODY_FIELDS, "manifest.body");210 if (manifest.body.manifestVersion !== DEVICE_MANIFEST_VERSION) {211 throw new DeviceManifestError(212 "version",213 `unsupported manifest version ${manifest.body.manifestVersion}`,214 );215 }216 if (217 typeof manifest.body.issuedAt !== "number" ||218 !Number.isFinite(manifest.body.issuedAt) ||219 Math.abs(now - manifest.body.issuedAt) > freshness220 ) {221 throw new DeviceManifestError(222 "freshness",223 `manifest issuedAt ${manifest.body.issuedAt} outside ±${freshness}ms window`,224 );225 }226 if (!isBoundedIdentifier(manifest.body.issuerDeviceId)) {227 throw new DeviceManifestError("malformed", "issuerDeviceId invalid");228 }229 if (!Array.isArray(manifest.body.devices)) {230 throw new DeviceManifestError("malformed", "devices is not an array");231 }232 if (manifest.body.devices.length > MAX_MANIFEST_DEVICES) {233 throw new DeviceManifestError("malformed", "devices exceeds MAX_MANIFEST_DEVICES");234 }235 if (!Array.isArray(manifest.body.revoked)) {236 throw new DeviceManifestError("malformed", "revoked is not an array");237 }238 if (manifest.body.revoked.length > MAX_MANIFEST_REVOCATIONS) {239 throw new DeviceManifestError("malformed", "revoked exceeds MAX_MANIFEST_REVOCATIONS");240 }241242 for (const device of manifest.body.devices) {243 if (!isRecord(device)) {244 throw new DeviceManifestError("malformed", "device is not an object");245 }246 assertAllowedFields(device, MANIFEST_DEVICE_FIELDS, "device");247 if (!isBoundedIdentifier(device.deviceId)) {248 throw new DeviceManifestError("malformed", "device.deviceId missing");249 }250 if (251 typeof device.deviceName !== "string" ||252 device.deviceName.length === 0 ||253 !fitsUtf8Budget(device.deviceName, MAX_MANIFEST_DEVICE_NAME_BYTES)254 ) {255 throw new DeviceManifestError("malformed", "device.deviceName invalid");256 }257 if (258 device.deviceType !== "extension" &&259 device.deviceType !== "android" &&260 device.deviceType !== "ios"261 ) {262 throw new DeviceManifestError("malformed", "device.deviceType invalid");263 }264 if (265 typeof device.longTermPublicKeyHex !== "string" ||266 device.longTermPublicKeyHex.length !== 64267 ) {268 throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid");269 }270 if (271 typeof device.signingPublicKeyHex !== "string" ||272 device.signingPublicKeyHex.length !== 64273 ) {274 throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");275 }276 if (277 device.vouchDepth != null &&278 (279 !Number.isInteger(device.vouchDepth) ||280 (device.vouchDepth as number) < 0 ||281 (device.vouchDepth as number) > MAX_VOUCH_DEPTH282 )283 ) {284 throw new DeviceManifestError("malformed", "device.vouchDepth invalid");285 }286 if (287 typeof device.pairedAt !== "number" ||288 !Number.isFinite(device.pairedAt) ||289 device.pairedAt < 0290 ) {291 throw new DeviceManifestError("malformed", "device.pairedAt invalid");292 }293 if (294 device.wsPort != null &&295 (296 typeof device.wsPort !== "number" ||297 !Number.isInteger(device.wsPort) ||298 device.wsPort < 1 ||299 device.wsPort > 65_535300 )301 ) {302 throw new DeviceManifestError("malformed", "device.wsPort invalid");303 }304 if (device.relayUrl != null) {305 if (306 typeof device.relayUrl !== "string" ||307 !fitsUtf8Budget(device.relayUrl, MAX_MANIFEST_RELAY_URL_BYTES)308 ) {309 throw new DeviceManifestError("malformed", "device.relayUrl invalid");310 }311 try {312 normalizeRelayUrl(device.relayUrl);313 } catch {314 throw new DeviceManifestError("malformed", "device.relayUrl invalid");315 }316 }317 }318319 for (const revocation of manifest.body.revoked) {320 if (!isRecord(revocation)) {321 throw new DeviceManifestError("malformed", "revocation is not an object");322 }323 assertAllowedFields(revocation, MANIFEST_REVOCATION_FIELDS, "revocation");324 if (!isBoundedIdentifier(revocation.deviceId)) {325 throw new DeviceManifestError("malformed", "revocation.deviceId invalid");326 }327 if (328 typeof revocation.longTermPublicKeyHex !== "string" ||329 !/^[0-9a-fA-F]{64}$/.test(revocation.longTermPublicKeyHex)330 ) {331 throw new DeviceManifestError(332 "malformed",333 "revocation.longTermPublicKeyHex invalid",334 );335 }336 if (typeof revocation.revokedAt !== "number" || !Number.isFinite(revocation.revokedAt)) {337 throw new DeviceManifestError("malformed", "revocation.revokedAt invalid");338 }339 if (!isBoundedIdentifier(revocation.revokedByDeviceId)) {340 throw new DeviceManifestError("malformed", "revocation.revokedByDeviceId invalid");341 }342 if (343 revocation.recordVersion != null &&344 revocation.recordVersion !== DEVICE_REVOCATION_RECORD_VERSION345 ) {346 throw new DeviceManifestError(347 "malformed",348 `revocation.recordVersion unsupported (${revocation.recordVersion})`,349 );350 }351 if (352 revocation.signatureHex != null &&353 (354 typeof revocation.signatureHex !== "string" ||355 !fitsUtf8Budget(revocation.signatureHex, MAX_REVOCATION_SIGNATURE_BYTES)356 )357 ) {358 throw new DeviceManifestError("malformed", "revocation.signatureHex invalid");359 }360 }361}362363364365366367368369370export async function verifyDeviceManifest(371 manifest: SignedDeviceManifest,372 issuerSigningPubkey: Uint8Array,373 options: { now?: number; freshnessMs?: number } = {},374): Promise<DeviceManifestBody> {375 const now = options.now ?? Date.now();376 const freshness = options.freshnessMs ?? DEVICE_MANIFEST_FRESHNESS_MS;377 preflightDeviceManifest(manifest, now, freshness);378379 const signature = hexToBytes(manifest.signatureHex);380 const canonical = canonicalise(manifest.body);381 const ok = await verifyEd25519(382 issuerSigningPubkey,383 new TextEncoder().encode(canonical),384 signature,385 );386 if (!ok) throw new DeviceManifestError("signature", "Ed25519 signature did not verify");387 return manifest.body;388}389390function normaliseRevocation(r: DeviceRevocation): DeviceRevocation {391 const normalised: DeviceRevocation = {392 deviceId: r.deviceId,393 longTermPublicKeyHex: r.longTermPublicKeyHex.toLowerCase(),394 revokedAt: r.revokedAt,395 revokedByDeviceId: r.revokedByDeviceId,396 recordVersion: r.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,397 };398 if (r.signatureHex != null) normalised.signatureHex = r.signatureHex.toLowerCase();399 return normalised;400}401402function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {403 const normalised: ManifestDeviceEntry = {404 deviceId: d.deviceId,405 deviceName: capDeviceName(d.deviceName),406 deviceType: d.deviceType,407 longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(),408 signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),409 pairedAt: d.pairedAt,410 vouchDepth: d.vouchDepth ?? 0,411 };412 if (d.wsPort != null) normalised.wsPort = d.wsPort;413 if (d.relayUrl != null) normalised.relayUrl = normalizeRelayUrl(d.relayUrl);414 return normalised;415}416417418419420421422423export function canonicalise(value: unknown): string {424 if (value === null) return "null";425 if (typeof value === "number") {426 if (!Number.isFinite(value)) throw new Error("non-finite number");427 return Number.isInteger(value) ? value.toString() : value.toString();428 }429 if (typeof value === "string") return JSON.stringify(value);430 if (typeof value === "boolean") return value ? "true" : "false";431 if (Array.isArray(value)) {432 return "[" + value.map((v) => canonicalise(v)).join(",") + "]";433 }434 if (typeof value === "object") {435 const obj = value as Record<string, unknown>;436 const keys = Object.keys(obj).sort();437 438 439 const present = keys.filter((k) => obj[k] !== null);440 return (441 "{" +442 present.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +443 "}"444 );445 }446 throw new Error(`cannot canonicalise ${typeof value}`);447}448449function bytesToHex(b: Uint8Array): string {450 let s = "";451 for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");452 return s;453}454455function hexToBytes(hex: string): Uint8Array {456 if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex");457 const out = new Uint8Array(hex.length / 2);458 for (let i = 0; i < out.length; i++) {459 const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16);460 if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex");461 out[i] = b;462 }463 return out;464}