123456789101112131415161718192021222324252627import {28 DEVICE_MANIFEST_FRESHNESS_MS,29 DEVICE_MANIFEST_VERSION,30 MAX_VOUCH_DEPTH,31 type DeviceManifestBody,32 type DeviceRevocation,33 type ManifestDeviceEntry,34 type SignedDeviceManifest,35} from "./sync-types";36import { signEd25519, verifyEd25519 } from "./sync-identity";37import { normalizeRelayUrl } from "./rendezvous";38import { capDeviceName } from "./sync-limits";3940export class DeviceManifestError extends Error {41 readonly kind:42 | "version"43 | "freshness"44 | "signature"45 | "issuer_mismatch"46 | "malformed";47 constructor(kind: DeviceManifestError["kind"], message: string) {48 super(message);49 this.kind = kind;50 }51}525354555657585960export async function buildSignedDeviceManifest(61 issuerDeviceId: string,62 devices: ManifestDeviceEntry[],63 signingPrivateKey: CryptoKey,64 options: { now?: number; revoked?: DeviceRevocation[] } = {},65): Promise<SignedDeviceManifest> {66 if (!issuerDeviceId) throw new Error("issuerDeviceId is required");67 const body: DeviceManifestBody = {68 manifestVersion: DEVICE_MANIFEST_VERSION,69 issuerDeviceId,70 issuedAt: options.now ?? Date.now(),71 devices: devices.map(normaliseEntry),72 revoked: (options.revoked ?? []).map(normaliseRevocation),73 };74 const canonical = canonicalise(body);75 const signature = await signEd25519(76 signingPrivateKey,77 new TextEncoder().encode(canonical),78 );79 return { body, signatureHex: bytesToHex(signature) };80}8182export function canonicalDeviceRevocation(record: DeviceRevocation): string {83 return canonicalise({84 deviceId: record.deviceId,85 longTermPublicKeyHex: record.longTermPublicKeyHex.toLowerCase(),86 revokedAt: record.revokedAt,87 revokedByDeviceId: record.revokedByDeviceId,88 issuerSigningKeyVersion: record.issuerSigningKeyVersion ?? 1,89 issuerSigningPublicKeyHex: record.issuerSigningPublicKeyHex?.toLowerCase() ?? "",90 });91}9293export async function signDeviceRevocation(94 record: DeviceRevocation,95 signingPrivateKey: CryptoKey,96 issuerSigningPublicKeyHex: string,97 issuerSigningKeyVersion = 1,98): Promise<DeviceRevocation> {99 const signed = {100 ...record,101 issuerSigningKeyVersion,102 issuerSigningPublicKeyHex: issuerSigningPublicKeyHex.toLowerCase(),103 };104 const signature = await signEd25519(105 signingPrivateKey,106 new TextEncoder().encode(canonicalDeviceRevocation(signed)),107 );108 return { ...signed, signatureHex: bytesToHex(signature) };109}110111export async function verifyDeviceRevocation(112 record: DeviceRevocation,113 issuerSigningPubkey: Uint8Array,114): Promise<boolean> {115 if (!record.signatureHex || !/^[0-9a-f]{128}$/i.test(record.signatureHex)) return false;116 try {117 return await verifyEd25519(118 issuerSigningPubkey,119 new TextEncoder().encode(canonicalDeviceRevocation(record)),120 hexToBytes(record.signatureHex),121 );122 } catch {123 return false;124 }125}126127128129130131132133134export async function verifyDeviceManifest(135 manifest: SignedDeviceManifest,136 issuerSigningPubkey: Uint8Array,137 options: { now?: number; freshnessMs?: number } = {},138): Promise<DeviceManifestBody> {139 if (!manifest || typeof manifest !== "object") {140 throw new DeviceManifestError("malformed", "manifest is not an object");141 }142 if (!manifest.body || !manifest.signatureHex) {143 throw new DeviceManifestError("malformed", "manifest missing body or signature");144 }145 if (manifest.body.manifestVersion !== DEVICE_MANIFEST_VERSION) {146 throw new DeviceManifestError(147 "version",148 `unsupported manifest version ${manifest.body.manifestVersion}`,149 );150 }151 const now = options.now ?? Date.now();152 const freshness = options.freshnessMs ?? DEVICE_MANIFEST_FRESHNESS_MS;153 if (154 typeof manifest.body.issuedAt !== "number" ||155 Math.abs(now - manifest.body.issuedAt) > freshness156 ) {157 throw new DeviceManifestError(158 "freshness",159 `manifest issuedAt ${manifest.body.issuedAt} outside ±${freshness}ms window`,160 );161 }162163 const signature = hexToBytes(manifest.signatureHex);164 if (signature.length !== 64) {165 throw new DeviceManifestError("signature", "signature must be 64 bytes");166 }167 const canonical = canonicalise(manifest.body);168 const ok = await verifyEd25519(169 issuerSigningPubkey,170 new TextEncoder().encode(canonical),171 signature,172 );173 if (!ok) throw new DeviceManifestError("signature", "Ed25519 signature did not verify");174175 176 177 178 if (!Array.isArray(manifest.body.devices)) {179 throw new DeviceManifestError("malformed", "devices is not an array");180 }181 for (const d of manifest.body.devices) {182 if (typeof d.deviceId !== "string" || !d.deviceId) {183 throw new DeviceManifestError("malformed", "device.deviceId missing");184 }185 if (typeof d.deviceName !== "string" || !d.deviceName) {186 throw new DeviceManifestError("malformed", "device.deviceName missing");187 }188 if (189 d.deviceType !== "extension" &&190 d.deviceType !== "android" &&191 d.deviceType !== "ios"192 ) {193 throw new DeviceManifestError("malformed", "device.deviceType invalid");194 }195 if (typeof d.longTermPublicKeyHex !== "string" || d.longTermPublicKeyHex.length !== 64) {196 throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid");197 }198 if (typeof d.signingPublicKeyHex !== "string" || d.signingPublicKeyHex.length !== 64) {199 throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");200 }201 if (d.vouchDepth != null && (!Number.isInteger(d.vouchDepth) || d.vouchDepth < 0 || d.vouchDepth > MAX_VOUCH_DEPTH)) {202 throw new DeviceManifestError("malformed", "device.vouchDepth invalid");203 }204 if (205 d.wsPort != null &&206 (207 typeof d.wsPort !== "number" ||208 !Number.isInteger(d.wsPort) ||209 d.wsPort < 0 ||210 d.wsPort > 65535211 )212 ) {213 throw new DeviceManifestError("malformed", "device.wsPort invalid");214 }215 if (d.relayUrl != null) {216 try {217 normalizeRelayUrl(d.relayUrl);218 } catch {219 throw new DeviceManifestError("malformed", "device.relayUrl invalid");220 }221 }222 }223224 if (!Array.isArray(manifest.body.revoked)) {225 throw new DeviceManifestError("malformed", "revoked is not an array");226 }227 for (const r of manifest.body.revoked) {228 if (typeof r.deviceId !== "string" || !r.deviceId) {229 throw new DeviceManifestError("malformed", "revocation.deviceId missing");230 }231 if (232 typeof r.longTermPublicKeyHex !== "string" ||233 !/^[0-9a-fA-F]{64}$/.test(r.longTermPublicKeyHex)234 ) {235 throw new DeviceManifestError(236 "malformed",237 "revocation.longTermPublicKeyHex invalid",238 );239 }240 if (typeof r.revokedAt !== "number" || !Number.isFinite(r.revokedAt)) {241 throw new DeviceManifestError("malformed", "revocation.revokedAt invalid");242 }243 if (typeof r.revokedByDeviceId !== "string" || !r.revokedByDeviceId) {244 throw new DeviceManifestError(245 "malformed",246 "revocation.revokedByDeviceId missing",247 );248 }249 }250 return manifest.body;251}252253function normaliseRevocation(r: DeviceRevocation): DeviceRevocation {254 const normalised: DeviceRevocation = {255 deviceId: r.deviceId,256 longTermPublicKeyHex: r.longTermPublicKeyHex.toLowerCase(),257 revokedAt: r.revokedAt,258 revokedByDeviceId: r.revokedByDeviceId,259 };260 if (r.issuerSigningKeyVersion != null) normalised.issuerSigningKeyVersion = r.issuerSigningKeyVersion;261 if (r.issuerSigningPublicKeyHex != null) normalised.issuerSigningPublicKeyHex = r.issuerSigningPublicKeyHex.toLowerCase();262 if (r.signatureHex != null) normalised.signatureHex = r.signatureHex.toLowerCase();263 return normalised;264}265266function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {267 const normalised: ManifestDeviceEntry = {268 deviceId: d.deviceId,269 deviceName: capDeviceName(d.deviceName),270 deviceType: d.deviceType,271 longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(),272 signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),273 pairedAt: d.pairedAt,274 vouchDepth: d.vouchDepth ?? 0,275 };276 if (d.wsPort != null) normalised.wsPort = d.wsPort;277 if (d.relayUrl != null) normalised.relayUrl = normalizeRelayUrl(d.relayUrl);278 return normalised;279}280281282283284285286287export function canonicalise(value: unknown): string {288 if (value === null) return "null";289 if (typeof value === "number") {290 if (!Number.isFinite(value)) throw new Error("non-finite number");291 return Number.isInteger(value) ? value.toString() : value.toString();292 }293 if (typeof value === "string") return JSON.stringify(value);294 if (typeof value === "boolean") return value ? "true" : "false";295 if (Array.isArray(value)) {296 return "[" + value.map((v) => canonicalise(v)).join(",") + "]";297 }298 if (typeof value === "object") {299 const obj = value as Record<string, unknown>;300 const keys = Object.keys(obj).sort();301 302 303 const present = keys.filter((k) => obj[k] !== null);304 return (305 "{" +306 present.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +307 "}"308 );309 }310 throw new Error(`cannot canonicalise ${typeof value}`);311}312313function bytesToHex(b: Uint8Array): string {314 let s = "";315 for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");316 return s;317}318319function hexToBytes(hex: string): Uint8Array {320 if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex");321 const out = new Uint8Array(hex.length / 2);322 for (let i = 0; i < out.length; i++) {323 const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16);324 if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex");325 out[i] = b;326 }327 return out;328}