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 ) {140 throw new DeviceManifestError("malformed", "device.deviceType invalid");141 }142 if (typeof d.longTermPublicKeyHex !== "string" || d.longTermPublicKeyHex.length !== 64) {143 throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid");144 }145 if (typeof d.signingPublicKeyHex !== "string" || d.signingPublicKeyHex.length !== 64) {146 throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");147 }148 if (149 d.wsPort !== undefined &&150 (151 typeof d.wsPort !== "number" ||152 !Number.isInteger(d.wsPort) ||153 d.wsPort < 1 ||154 d.wsPort > 65535155 )156 ) {157 throw new DeviceManifestError("malformed", "device.wsPort invalid");158 }159 }160 return manifest.body;161}162163function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {164 const normalised: ManifestDeviceEntry = {165 deviceId: d.deviceId,166 deviceName: d.deviceName,167 deviceType: d.deviceType,168 longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(),169 signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),170 pairedAt: d.pairedAt,171 };172 if (d.wsPort !== undefined) normalised.wsPort = d.wsPort;173 return normalised;174}175176177178179180181182export function canonicalise(value: unknown): string {183 if (value === null) return "null";184 if (typeof value === "number") {185 if (!Number.isFinite(value)) throw new Error("non-finite number");186 return Number.isInteger(value) ? value.toString() : value.toString();187 }188 if (typeof value === "string") return JSON.stringify(value);189 if (typeof value === "boolean") return value ? "true" : "false";190 if (Array.isArray(value)) {191 return "[" + value.map((v) => canonicalise(v)).join(",") + "]";192 }193 if (typeof value === "object") {194 const obj = value as Record<string, unknown>;195 const keys = Object.keys(obj).sort();196 return (197 "{" +198 keys.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +199 "}"200 );201 }202 throw new Error(`cannot canonicalise ${typeof value}`);203}204205function bytesToHex(b: Uint8Array): string {206 let s = "";207 for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");208 return s;209}210211function hexToBytes(hex: string): Uint8Array {212 if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex");213 const out = new Uint8Array(hex.length / 2);214 for (let i = 0; i < out.length; i++) {215 const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16);216 if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex");217 out[i] = b;218 }219 return out;220}