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