1234567891011121314151617181920212223242526import {27 DEVICE_MANIFEST_FRESHNESS_MS,28 DEVICE_MANIFEST_VERSION,29 MAX_VOUCH_DEPTH,30 type DeviceManifestBody,31 type DeviceRevocation,32 type ManifestDeviceEntry,33 type SignedDeviceManifest,34} from "./sync-types";35import { signEd25519, verifyEd25519 } from "./sync-identity";36import { normalizeRelayUrl } from "./rendezvous";37import { capDeviceName } from "./sync-limits";3839export class DeviceManifestError extends Error {40 readonly kind:41 | "version"42 | "freshness"43 | "signature"44 | "issuer_mismatch"45 | "malformed";46 constructor(kind: DeviceManifestError["kind"], message: string) {47 super(message);48 this.kind = kind;49 }50}515253545556575859export async function buildSignedDeviceManifest(60 issuerDeviceId: string,61 devices: ManifestDeviceEntry[],62 signingPrivateKey: CryptoKey,63 options: { now?: number; revoked?: DeviceRevocation[] } = {},64): Promise<SignedDeviceManifest> {65 if (!issuerDeviceId) throw new Error("issuerDeviceId is required");66 const body: DeviceManifestBody = {67 manifestVersion: DEVICE_MANIFEST_VERSION,68 issuerDeviceId,69 issuedAt: options.now ?? Date.now(),70 devices: devices.map(normaliseEntry),71 revoked: (options.revoked ?? []).map(normaliseRevocation),72 };73 const canonical = canonicalise(body);74 const signature = await signEd25519(75 signingPrivateKey,76 new TextEncoder().encode(canonical),77 );78 return { body, signatureHex: bytesToHex(signature) };79}808182838485868788export async function verifyDeviceManifest(89 manifest: SignedDeviceManifest,90 issuerSigningPubkey: Uint8Array,91 options: { now?: number; freshnessMs?: number } = {},92): Promise<DeviceManifestBody> {93 if (!manifest || typeof manifest !== "object") {94 throw new DeviceManifestError("malformed", "manifest is not an object");95 }96 if (!manifest.body || !manifest.signatureHex) {97 throw new DeviceManifestError("malformed", "manifest missing body or signature");98 }99 if (manifest.body.manifestVersion !== DEVICE_MANIFEST_VERSION) {100 throw new DeviceManifestError(101 "version",102 `unsupported manifest version ${manifest.body.manifestVersion}`,103 );104 }105 const now = options.now ?? Date.now();106 const freshness = options.freshnessMs ?? DEVICE_MANIFEST_FRESHNESS_MS;107 if (108 typeof manifest.body.issuedAt !== "number" ||109 Math.abs(now - manifest.body.issuedAt) > freshness110 ) {111 throw new DeviceManifestError(112 "freshness",113 `manifest issuedAt ${manifest.body.issuedAt} outside ±${freshness}ms window`,114 );115 }116117 const signature = hexToBytes(manifest.signatureHex);118 if (signature.length !== 64) {119 throw new DeviceManifestError("signature", "signature must be 64 bytes");120 }121 const canonical = canonicalise(manifest.body);122 const ok = await verifyEd25519(123 issuerSigningPubkey,124 new TextEncoder().encode(canonical),125 signature,126 );127 if (!ok) throw new DeviceManifestError("signature", "Ed25519 signature did not verify");128129 130 131 132 if (!Array.isArray(manifest.body.devices)) {133 throw new DeviceManifestError("malformed", "devices is not an array");134 }135 for (const d of manifest.body.devices) {136 if (typeof d.deviceId !== "string" || !d.deviceId) {137 throw new DeviceManifestError("malformed", "device.deviceId missing");138 }139 if (typeof d.deviceName !== "string" || !d.deviceName) {140 throw new DeviceManifestError("malformed", "device.deviceName missing");141 }142 if (143 d.deviceType !== "extension" &&144 d.deviceType !== "android" &&145 d.deviceType !== "ios"146 ) {147 throw new DeviceManifestError("malformed", "device.deviceType invalid");148 }149 if (typeof d.longTermPublicKeyHex !== "string" || d.longTermPublicKeyHex.length !== 64) {150 throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid");151 }152 if (typeof d.signingPublicKeyHex !== "string" || d.signingPublicKeyHex.length !== 64) {153 throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");154 }155 if (d.vouchDepth != null && (!Number.isInteger(d.vouchDepth) || d.vouchDepth < 0 || d.vouchDepth > MAX_VOUCH_DEPTH)) {156 throw new DeviceManifestError("malformed", "device.vouchDepth invalid");157 }158 if (159 d.wsPort != null &&160 (161 typeof d.wsPort !== "number" ||162 !Number.isInteger(d.wsPort) ||163 d.wsPort < 0 ||164 d.wsPort > 65535165 )166 ) {167 throw new DeviceManifestError("malformed", "device.wsPort invalid");168 }169 if (d.relayUrl != null) {170 try {171 normalizeRelayUrl(d.relayUrl);172 } catch {173 throw new DeviceManifestError("malformed", "device.relayUrl invalid");174 }175 }176 }177178 if (!Array.isArray(manifest.body.revoked)) {179 throw new DeviceManifestError("malformed", "revoked is not an array");180 }181 for (const r of manifest.body.revoked) {182 if (typeof r.deviceId !== "string" || !r.deviceId) {183 throw new DeviceManifestError("malformed", "revocation.deviceId missing");184 }185 if (186 typeof r.longTermPublicKeyHex !== "string" ||187 !/^[0-9a-fA-F]{64}$/.test(r.longTermPublicKeyHex)188 ) {189 throw new DeviceManifestError(190 "malformed",191 "revocation.longTermPublicKeyHex invalid",192 );193 }194 if (typeof r.revokedAt !== "number" || !Number.isFinite(r.revokedAt)) {195 throw new DeviceManifestError("malformed", "revocation.revokedAt invalid");196 }197 if (typeof r.revokedByDeviceId !== "string" || !r.revokedByDeviceId) {198 throw new DeviceManifestError(199 "malformed",200 "revocation.revokedByDeviceId missing",201 );202 }203 }204 return manifest.body;205}206207function normaliseRevocation(r: DeviceRevocation): DeviceRevocation {208 return {209 deviceId: r.deviceId,210 longTermPublicKeyHex: r.longTermPublicKeyHex.toLowerCase(),211 revokedAt: r.revokedAt,212 revokedByDeviceId: r.revokedByDeviceId,213 };214}215216function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {217 const normalised: ManifestDeviceEntry = {218 deviceId: d.deviceId,219 deviceName: capDeviceName(d.deviceName),220 deviceType: d.deviceType,221 longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(),222 signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),223 pairedAt: d.pairedAt,224 vouchDepth: d.vouchDepth ?? 0,225 };226 if (d.wsPort != null) normalised.wsPort = d.wsPort;227 if (d.relayUrl != null) normalised.relayUrl = normalizeRelayUrl(d.relayUrl);228 return normalised;229}230231232233234235236237export function canonicalise(value: unknown): string {238 if (value === null) return "null";239 if (typeof value === "number") {240 if (!Number.isFinite(value)) throw new Error("non-finite number");241 return Number.isInteger(value) ? value.toString() : value.toString();242 }243 if (typeof value === "string") return JSON.stringify(value);244 if (typeof value === "boolean") return value ? "true" : "false";245 if (Array.isArray(value)) {246 return "[" + value.map((v) => canonicalise(v)).join(",") + "]";247 }248 if (typeof value === "object") {249 const obj = value as Record<string, unknown>;250 const keys = Object.keys(obj).sort();251 252 253 const present = keys.filter((k) => obj[k] !== null);254 return (255 "{" +256 present.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +257 "}"258 );259 }260 throw new Error(`cannot canonicalise ${typeof value}`);261}262263function bytesToHex(b: Uint8Array): string {264 let s = "";265 for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");266 return s;267}268269function hexToBytes(hex: string): Uint8Array {270 if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex");271 const out = new Uint8Array(hex.length / 2);272 for (let i = 0; i < out.length; i++) {273 const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16);274 if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex");275 out[i] = b;276 }277 return out;278}