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.longTermPublicKeyHex !== "string" || d.longTermPublicKeyHex.length !== 64) {133 throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid");134 }135 if (typeof d.signingPublicKeyHex !== "string" || d.signingPublicKeyHex.length !== 64) {136 throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");137 }138 }139 return manifest.body;140}141142function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {143 return {144 deviceId: d.deviceId,145 deviceName: d.deviceName,146 deviceType: d.deviceType,147 longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(),148 signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),149 pairedAt: d.pairedAt,150 };151}152153154155156157158159export function canonicalise(value: unknown): string {160 if (value === null) return "null";161 if (typeof value === "number") {162 if (!Number.isFinite(value)) throw new Error("non-finite number");163 return Number.isInteger(value) ? value.toString() : value.toString();164 }165 if (typeof value === "string") return JSON.stringify(value);166 if (typeof value === "boolean") return value ? "true" : "false";167 if (Array.isArray(value)) {168 return "[" + value.map((v) => canonicalise(v)).join(",") + "]";169 }170 if (typeof value === "object") {171 const obj = value as Record<string, unknown>;172 const keys = Object.keys(obj).sort();173 return (174 "{" +175 keys.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +176 "}"177 );178 }179 throw new Error(`cannot canonicalise ${typeof value}`);180}181182function bytesToHex(b: Uint8Array): string {183 let s = "";184 for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");185 return s;186}187188function hexToBytes(hex: string): Uint8Array {189 if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex");190 const out = new Uint8Array(hex.length / 2);191 for (let i = 0; i < out.length; i++) {192 const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16);193 if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex");194 out[i] = b;195 }196 return out;197}