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