123456789101112131415161718192021222324import {25 DEVICE_MANIFEST_FRESHNESS_MS,26 DEVICE_MANIFEST_VERSION,27 type DeviceManifestBody,28 type ManifestDeviceEntry,29 type SignedDeviceManifest,30} from "./sync-types";31import { signEd25519, verifyEd25519 } from "./sync-identity";3233export class DeviceManifestError extends Error {34 readonly kind:35 | "version"36 | "freshness"37 | "signature"38 | "issuer_mismatch"39 | "malformed";40 constructor(kind: DeviceManifestError["kind"], message: string) {41 super(message);42 this.kind = kind;43 }44}454647484950515253export async function buildSignedDeviceManifest(54 issuerDeviceId: string,55 devices: ManifestDeviceEntry[],56 signingPrivateKey: CryptoKey,57 options: { now?: number } = {},58): Promise<SignedDeviceManifest> {59 if (!issuerDeviceId) throw new Error("issuerDeviceId is required");60 const body: DeviceManifestBody = {61 manifestVersion: DEVICE_MANIFEST_VERSION,62 issuerDeviceId,63 issuedAt: options.now ?? Date.now(),64 devices: devices.map(normaliseEntry),65 };66 const canonical = canonicalise(body);67 const signature = await signEd25519(68 signingPrivateKey,69 new TextEncoder().encode(canonical),70 );71 return { body, signatureHex: bytesToHex(signature) };72}737475767778798081export async function verifyDeviceManifest(82 manifest: SignedDeviceManifest,83 issuerSigningPubkey: Uint8Array,84 options: { now?: number; freshnessMs?: number } = {},85): Promise<DeviceManifestBody> {86 if (!manifest || typeof manifest !== "object") {87 throw new DeviceManifestError("malformed", "manifest is not an object");88 }89 if (!manifest.body || !manifest.signatureHex) {90 throw new DeviceManifestError("malformed", "manifest missing body or signature");91 }92 if (manifest.body.manifestVersion !== DEVICE_MANIFEST_VERSION) {93 throw new DeviceManifestError(94 "version",95 `unsupported manifest version ${manifest.body.manifestVersion}`,96 );97 }98 const now = options.now ?? Date.now();99 const freshness = options.freshnessMs ?? DEVICE_MANIFEST_FRESHNESS_MS;100 if (101 typeof manifest.body.issuedAt !== "number" ||102 Math.abs(now - manifest.body.issuedAt) > freshness103 ) {104 throw new DeviceManifestError(105 "freshness",106 `manifest issuedAt ${manifest.body.issuedAt} outside ±${freshness}ms window`,107 );108 }109110 const signature = hexToBytes(manifest.signatureHex);111 if (signature.length !== 64) {112 throw new DeviceManifestError("signature", "signature must be 64 bytes");113 }114 const canonical = canonicalise(manifest.body);115 const ok = await verifyEd25519(116 issuerSigningPubkey,117 new TextEncoder().encode(canonical),118 signature,119 );120 if (!ok) throw new DeviceManifestError("signature", "Ed25519 signature did not verify");121122 123 124 125 if (!Array.isArray(manifest.body.devices)) {126 throw new DeviceManifestError("malformed", "devices is not an array");127 }128 for (const d of manifest.body.devices) {129 if (typeof d.deviceId !== "string" || !d.deviceId) {130 throw new DeviceManifestError("malformed", "device.deviceId missing");131 }132 if (typeof d.deviceName !== "string" || !d.deviceName) {133 throw new DeviceManifestError("malformed", "device.deviceName missing");134 }135 if (136 d.deviceType !== "extension" &&137 d.deviceType !== "android" &&138 d.deviceType !== "ios" &&139 d.deviceType !== "desktop"140 ) {141 throw new DeviceManifestError("malformed", "device.deviceType invalid");142 }143 if (typeof d.longTermPublicKeyHex !== "string" || d.longTermPublicKeyHex.length !== 64) {144 throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid");145 }146 if (typeof d.signingPublicKeyHex !== "string" || d.signingPublicKeyHex.length !== 64) {147 throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");148 }149 if (150 d.wsPort !== undefined &&151 (152 typeof d.wsPort !== "number" ||153 !Number.isInteger(d.wsPort) ||154 d.wsPort < 1 ||155 d.wsPort > 65535156 )157 ) {158 throw new DeviceManifestError("malformed", "device.wsPort invalid");159 }160 }161 return manifest.body;162}163164function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {165 const normalised: ManifestDeviceEntry = {166 deviceId: d.deviceId,167 deviceName: d.deviceName,168 deviceType: d.deviceType,169 longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(),170 signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),171 pairedAt: d.pairedAt,172 };173 if (d.wsPort !== undefined) normalised.wsPort = d.wsPort;174 return normalised;175}176177178179180181182183export function canonicalise(value: unknown): string {184 if (value === null) return "null";185 if (typeof value === "number") {186 if (!Number.isFinite(value)) throw new Error("non-finite number");187 return Number.isInteger(value) ? value.toString() : value.toString();188 }189 if (typeof value === "string") return JSON.stringify(value);190 if (typeof value === "boolean") return value ? "true" : "false";191 if (Array.isArray(value)) {192 return "[" + value.map((v) => canonicalise(v)).join(",") + "]";193 }194 if (typeof value === "object") {195 const obj = value as Record<string, unknown>;196 const keys = Object.keys(obj).sort();197 return (198 "{" +199 keys.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +200 "}"201 );202 }203 throw new Error(`cannot canonicalise ${typeof value}`);204}205206function bytesToHex(b: Uint8Array): string {207 let s = "";208 for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");209 return s;210}211212function hexToBytes(hex: string): Uint8Array {213 if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex");214 const out = new Uint8Array(hex.length / 2);215 for (let i = 0; i < out.length; i++) {216 const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16);217 if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex");218 out[i] = b;219 }220 return out;221}