12345678910111213141516171819202122232425import {26 DEVICE_MANIFEST_FRESHNESS_MS,27 DEVICE_MANIFEST_VERSION,28 type DeviceManifestBody,29 type DeviceRevocation,30 type ManifestDeviceEntry,31 type SignedDeviceManifest,32} from "./sync-types";33import { signEd25519, verifyEd25519 } from "./sync-identity";3435export class DeviceManifestError extends Error {36 readonly kind:37 | "version"38 | "freshness"39 | "signature"40 | "issuer_mismatch"41 | "malformed";42 constructor(kind: DeviceManifestError["kind"], message: string) {43 super(message);44 this.kind = kind;45 }46}474849505152535455export async function buildSignedDeviceManifest(56 issuerDeviceId: string,57 devices: ManifestDeviceEntry[],58 signingPrivateKey: CryptoKey,59 options: { now?: number; revoked?: DeviceRevocation[] } = {},60): Promise<SignedDeviceManifest> {61 if (!issuerDeviceId) throw new Error("issuerDeviceId is required");62 const body: DeviceManifestBody = {63 manifestVersion: DEVICE_MANIFEST_VERSION,64 issuerDeviceId,65 issuedAt: options.now ?? Date.now(),66 devices: devices.map(normaliseEntry),67 revoked: (options.revoked ?? []).map(normaliseRevocation),68 };69 const canonical = canonicalise(body);70 const signature = await signEd25519(71 signingPrivateKey,72 new TextEncoder().encode(canonical),73 );74 return { body, signatureHex: bytesToHex(signature) };75}767778798081828384export async function verifyDeviceManifest(85 manifest: SignedDeviceManifest,86 issuerSigningPubkey: Uint8Array,87 options: { now?: number; freshnessMs?: number } = {},88): Promise<DeviceManifestBody> {89 if (!manifest || typeof manifest !== "object") {90 throw new DeviceManifestError("malformed", "manifest is not an object");91 }92 if (!manifest.body || !manifest.signatureHex) {93 throw new DeviceManifestError("malformed", "manifest missing body or signature");94 }95 if (manifest.body.manifestVersion !== DEVICE_MANIFEST_VERSION) {96 throw new DeviceManifestError(97 "version",98 `unsupported manifest version ${manifest.body.manifestVersion}`,99 );100 }101 const now = options.now ?? Date.now();102 const freshness = options.freshnessMs ?? DEVICE_MANIFEST_FRESHNESS_MS;103 if (104 typeof manifest.body.issuedAt !== "number" ||105 Math.abs(now - manifest.body.issuedAt) > freshness106 ) {107 throw new DeviceManifestError(108 "freshness",109 `manifest issuedAt ${manifest.body.issuedAt} outside ±${freshness}ms window`,110 );111 }112113 const signature = hexToBytes(manifest.signatureHex);114 if (signature.length !== 64) {115 throw new DeviceManifestError("signature", "signature must be 64 bytes");116 }117 const canonical = canonicalise(manifest.body);118 const ok = await verifyEd25519(119 issuerSigningPubkey,120 new TextEncoder().encode(canonical),121 signature,122 );123 if (!ok) throw new DeviceManifestError("signature", "Ed25519 signature did not verify");124125 126 127 128 if (!Array.isArray(manifest.body.devices)) {129 throw new DeviceManifestError("malformed", "devices is not an array");130 }131 for (const d of manifest.body.devices) {132 if (typeof d.deviceId !== "string" || !d.deviceId) {133 throw new DeviceManifestError("malformed", "device.deviceId missing");134 }135 if (typeof d.deviceName !== "string" || !d.deviceName) {136 throw new DeviceManifestError("malformed", "device.deviceName missing");137 }138 if (139 d.deviceType !== "extension" &&140 d.deviceType !== "android" &&141 d.deviceType !== "ios"142 ) {143 throw new DeviceManifestError("malformed", "device.deviceType invalid");144 }145 if (typeof d.longTermPublicKeyHex !== "string" || d.longTermPublicKeyHex.length !== 64) {146 throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid");147 }148 if (typeof d.signingPublicKeyHex !== "string" || d.signingPublicKeyHex.length !== 64) {149 throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");150 }151 if (152 d.wsPort !== undefined &&153 (154 typeof d.wsPort !== "number" ||155 !Number.isInteger(d.wsPort) ||156 d.wsPort < 1 ||157 d.wsPort > 65535158 )159 ) {160 throw new DeviceManifestError("malformed", "device.wsPort invalid");161 }162 }163164 if (!Array.isArray(manifest.body.revoked)) {165 throw new DeviceManifestError("malformed", "revoked is not an array");166 }167 for (const r of manifest.body.revoked) {168 if (typeof r.deviceId !== "string" || !r.deviceId) {169 throw new DeviceManifestError("malformed", "revocation.deviceId missing");170 }171 if (172 typeof r.longTermPublicKeyHex !== "string" ||173 !/^[0-9a-fA-F]{64}$/.test(r.longTermPublicKeyHex)174 ) {175 throw new DeviceManifestError(176 "malformed",177 "revocation.longTermPublicKeyHex invalid",178 );179 }180 if (typeof r.revokedAt !== "number" || !Number.isFinite(r.revokedAt)) {181 throw new DeviceManifestError("malformed", "revocation.revokedAt invalid");182 }183 if (typeof r.revokedByDeviceId !== "string" || !r.revokedByDeviceId) {184 throw new DeviceManifestError(185 "malformed",186 "revocation.revokedByDeviceId missing",187 );188 }189 }190 return manifest.body;191}192193function normaliseRevocation(r: DeviceRevocation): DeviceRevocation {194 return {195 deviceId: r.deviceId,196 longTermPublicKeyHex: r.longTermPublicKeyHex.toLowerCase(),197 revokedAt: r.revokedAt,198 revokedByDeviceId: r.revokedByDeviceId,199 };200}201202function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {203 const normalised: ManifestDeviceEntry = {204 deviceId: d.deviceId,205 deviceName: d.deviceName,206 deviceType: d.deviceType,207 longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(),208 signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),209 pairedAt: d.pairedAt,210 };211 if (d.wsPort !== undefined) normalised.wsPort = d.wsPort;212 return normalised;213}214215216217218219220221export function canonicalise(value: unknown): string {222 if (value === null) return "null";223 if (typeof value === "number") {224 if (!Number.isFinite(value)) throw new Error("non-finite number");225 return Number.isInteger(value) ? value.toString() : value.toString();226 }227 if (typeof value === "string") return JSON.stringify(value);228 if (typeof value === "boolean") return value ? "true" : "false";229 if (Array.isArray(value)) {230 return "[" + value.map((v) => canonicalise(v)).join(",") + "]";231 }232 if (typeof value === "object") {233 const obj = value as Record<string, unknown>;234 const keys = Object.keys(obj).sort();235 return (236 "{" +237 keys.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +238 "}"239 );240 }241 throw new Error(`cannot canonicalise ${typeof value}`);242}243244function bytesToHex(b: Uint8Array): string {245 let s = "";246 for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");247 return s;248}249250function hexToBytes(hex: string): Uint8Array {251 if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex");252 const out = new Uint8Array(hex.length / 2);253 for (let i = 0; i < out.length; i++) {254 const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16);255 if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex");256 out[i] = b;257 }258 return out;259}