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