123456789101112131415161718192021222324252627import {28 DEVICE_MANIFEST_FRESHNESS_MS,29 DEVICE_MANIFEST_VERSION,30 DEVICE_REVOCATION_RECORD_VERSION,31 MAX_VOUCH_DEPTH,32 type DeviceManifestBody,33 type DeviceRevocation,34 type ManifestDeviceEntry,35 type SignedDeviceManifest,36} from "./sync-types";37import { signEd25519, verifyEd25519 } from "./sync-identity";38import { normalizeRelayUrl } from "./rendezvous";39import { capDeviceName } from "./sync-limits";4041export class DeviceManifestError extends Error {42 readonly kind:43 | "version"44 | "freshness"45 | "signature"46 | "issuer_mismatch"47 | "malformed";48 constructor(kind: DeviceManifestError["kind"], message: string) {49 super(message);50 this.kind = kind;51 }52}535455565758596061export async function buildSignedDeviceManifest(62 issuerDeviceId: string,63 devices: ManifestDeviceEntry[],64 signingPrivateKey: CryptoKey,65 options: { now?: number; revoked?: DeviceRevocation[] } = {},66): Promise<SignedDeviceManifest> {67 if (!issuerDeviceId) throw new Error("issuerDeviceId is required");68 const body: DeviceManifestBody = {69 manifestVersion: DEVICE_MANIFEST_VERSION,70 issuerDeviceId,71 issuedAt: options.now ?? Date.now(),72 devices: devices.map(normaliseEntry),73 revoked: (options.revoked ?? []).map(normaliseRevocation),74 };75 const canonical = canonicalise(body);76 const signature = await signEd25519(77 signingPrivateKey,78 new TextEncoder().encode(canonical),79 );80 return { body, signatureHex: bytesToHex(signature) };81}828384858687const MAX_MANIFEST_DEVICES = 256;88const MAX_MANIFEST_REVOCATIONS = 500;89const MAX_MANIFEST_STRING = 256;909192export function canonicalDeviceRevocation(record: DeviceRevocation): string {93 return canonicalise({94 deviceId: record.deviceId,95 longTermPublicKeyHex: record.longTermPublicKeyHex.toLowerCase(),96 recordVersion: record.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,97 revokedAt: record.revokedAt,98 revokedByDeviceId: record.revokedByDeviceId,99 });100}101102export async function signDeviceRevocation(103 record: DeviceRevocation,104 signingPrivateKey: CryptoKey,105): Promise<DeviceRevocation> {106 const unsigned: DeviceRevocation = {107 ...record,108 longTermPublicKeyHex: record.longTermPublicKeyHex.toLowerCase(),109 recordVersion: record.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,110 };111 const signature = await signEd25519(112 signingPrivateKey,113 new TextEncoder().encode(canonicalDeviceRevocation(unsigned)),114 );115 return { ...unsigned, signatureHex: bytesToHex(signature) };116}117118export async function verifyDeviceRevocation(119 record: DeviceRevocation,120 issuerSigningPubkey: Uint8Array,121): Promise<boolean> {122 if (!record.signatureHex || !/^[0-9a-f]{128}$/i.test(record.signatureHex)) return false;123 try {124 return await verifyEd25519(125 issuerSigningPubkey,126 new TextEncoder().encode(canonicalDeviceRevocation(record)),127 hexToBytes(record.signatureHex),128 );129 } catch {130 return false;131 }132}133134135136137138139140141export async function verifyDeviceManifest(142 manifest: SignedDeviceManifest,143 issuerSigningPubkey: Uint8Array,144 options: { now?: number; freshnessMs?: number } = {},145): Promise<DeviceManifestBody> {146 if (!manifest || typeof manifest !== "object") {147 throw new DeviceManifestError("malformed", "manifest is not an object");148 }149 if (!manifest.body || !manifest.signatureHex) {150 throw new DeviceManifestError("malformed", "manifest missing body or signature");151 }152 if (manifest.body.manifestVersion !== DEVICE_MANIFEST_VERSION) {153 throw new DeviceManifestError(154 "version",155 `unsupported manifest version ${manifest.body.manifestVersion}`,156 );157 }158 const now = options.now ?? Date.now();159 const freshness = options.freshnessMs ?? DEVICE_MANIFEST_FRESHNESS_MS;160 if (161 typeof manifest.body.issuedAt !== "number" ||162 Math.abs(now - manifest.body.issuedAt) > freshness163 ) {164 throw new DeviceManifestError(165 "freshness",166 `manifest issuedAt ${manifest.body.issuedAt} outside ±${freshness}ms window`,167 );168 }169170 const signature = hexToBytes(manifest.signatureHex);171 if (signature.length !== 64) {172 throw new DeviceManifestError("signature", "signature must be 64 bytes");173 }174 const canonical = canonicalise(manifest.body);175 const ok = await verifyEd25519(176 issuerSigningPubkey,177 new TextEncoder().encode(canonical),178 signature,179 );180 if (!ok) throw new DeviceManifestError("signature", "Ed25519 signature did not verify");181182 183 184 185 if (!Array.isArray(manifest.body.devices)) {186 throw new DeviceManifestError("malformed", "devices is not an array");187 }188 if (189 typeof manifest.body.issuerDeviceId !== "string" ||190 manifest.body.issuerDeviceId.length === 0 ||191 manifest.body.issuerDeviceId.length > MAX_MANIFEST_STRING192 ) {193 throw new DeviceManifestError("malformed", "issuerDeviceId invalid");194 }195 if (manifest.body.devices.length > MAX_MANIFEST_DEVICES) {196 throw new DeviceManifestError("malformed", "devices exceeds MAX_MANIFEST_DEVICES");197 }198 if (!Array.isArray(manifest.body.revoked)) {199 throw new DeviceManifestError("malformed", "revoked is not an array");200 }201 if (manifest.body.revoked.length > MAX_MANIFEST_REVOCATIONS) {202 throw new DeviceManifestError("malformed", "revoked exceeds MAX_MANIFEST_REVOCATIONS");203 }204 for (const d of manifest.body.devices) {205 if (typeof d.deviceId !== "string" || !d.deviceId || d.deviceId.length > MAX_MANIFEST_STRING) {206 throw new DeviceManifestError("malformed", "device.deviceId missing");207 }208 if (typeof d.deviceName !== "string" || !d.deviceName) {209 throw new DeviceManifestError("malformed", "device.deviceName missing");210 }211 if (212 d.deviceType !== "extension" &&213 d.deviceType !== "android" &&214 d.deviceType !== "ios"215 ) {216 throw new DeviceManifestError("malformed", "device.deviceType invalid");217 }218 if (typeof d.longTermPublicKeyHex !== "string" || d.longTermPublicKeyHex.length !== 64) {219 throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid");220 }221 if (typeof d.signingPublicKeyHex !== "string" || d.signingPublicKeyHex.length !== 64) {222 throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");223 }224 if (d.vouchDepth != null && (!Number.isInteger(d.vouchDepth) || d.vouchDepth < 0 || d.vouchDepth > MAX_VOUCH_DEPTH)) {225 throw new DeviceManifestError("malformed", "device.vouchDepth invalid");226 }227 if (typeof d.pairedAt !== "number" || !Number.isFinite(d.pairedAt) || d.pairedAt < 0) {228 throw new DeviceManifestError("malformed", "device.pairedAt invalid");229 }230 if (231 d.wsPort != null &&232 (233 typeof d.wsPort !== "number" ||234 !Number.isInteger(d.wsPort) ||235 d.wsPort < 1 ||236 d.wsPort > 65535237 )238 ) {239 throw new DeviceManifestError("malformed", "device.wsPort invalid");240 }241 if (d.relayUrl != null) {242 try {243 normalizeRelayUrl(d.relayUrl);244 } catch {245 throw new DeviceManifestError("malformed", "device.relayUrl invalid");246 }247 }248 }249250 if (!Array.isArray(manifest.body.revoked)) {251 throw new DeviceManifestError("malformed", "revoked is not an array");252 }253 for (const r of manifest.body.revoked) {254 if (255 typeof r.deviceId !== "string" ||256 !r.deviceId ||257 r.deviceId.length > MAX_MANIFEST_STRING258 ) {259 throw new DeviceManifestError("malformed", "revocation.deviceId invalid");260 }261 if (262 typeof r.longTermPublicKeyHex !== "string" ||263 !/^[0-9a-fA-F]{64}$/.test(r.longTermPublicKeyHex)264 ) {265 throw new DeviceManifestError(266 "malformed",267 "revocation.longTermPublicKeyHex invalid",268 );269 }270 if (typeof r.revokedAt !== "number" || !Number.isFinite(r.revokedAt)) {271 throw new DeviceManifestError("malformed", "revocation.revokedAt invalid");272 }273 if (274 typeof r.revokedByDeviceId !== "string" ||275 !r.revokedByDeviceId ||276 r.revokedByDeviceId.length > MAX_MANIFEST_STRING277 ) {278 throw new DeviceManifestError("malformed", "revocation.revokedByDeviceId invalid");279 }280 if (281 r.recordVersion != null &&282 r.recordVersion !== DEVICE_REVOCATION_RECORD_VERSION283 ) {284 throw new DeviceManifestError(285 "malformed",286 `revocation.recordVersion unsupported (${r.recordVersion})`,287 );288 }289 }290 return manifest.body;291}292293function normaliseRevocation(r: DeviceRevocation): DeviceRevocation {294 const normalised: DeviceRevocation = {295 deviceId: r.deviceId,296 longTermPublicKeyHex: r.longTermPublicKeyHex.toLowerCase(),297 revokedAt: r.revokedAt,298 revokedByDeviceId: r.revokedByDeviceId,299 recordVersion: r.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,300 };301 if (r.signatureHex != null) normalised.signatureHex = r.signatureHex.toLowerCase();302 return normalised;303}304305function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {306 const normalised: ManifestDeviceEntry = {307 deviceId: d.deviceId,308 deviceName: capDeviceName(d.deviceName),309 deviceType: d.deviceType,310 longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(),311 signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),312 pairedAt: d.pairedAt,313 vouchDepth: d.vouchDepth ?? 0,314 };315 if (d.wsPort != null) normalised.wsPort = d.wsPort;316 if (d.relayUrl != null) normalised.relayUrl = normalizeRelayUrl(d.relayUrl);317 return normalised;318}319320321322323324325326export function canonicalise(value: unknown): string {327 if (value === null) return "null";328 if (typeof value === "number") {329 if (!Number.isFinite(value)) throw new Error("non-finite number");330 return Number.isInteger(value) ? value.toString() : value.toString();331 }332 if (typeof value === "string") return JSON.stringify(value);333 if (typeof value === "boolean") return value ? "true" : "false";334 if (Array.isArray(value)) {335 return "[" + value.map((v) => canonicalise(v)).join(",") + "]";336 }337 if (typeof value === "object") {338 const obj = value as Record<string, unknown>;339 const keys = Object.keys(obj).sort();340 341 342 const present = keys.filter((k) => obj[k] !== null);343 return (344 "{" +345 present.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +346 "}"347 );348 }349 throw new Error(`cannot canonicalise ${typeof value}`);350}351352function bytesToHex(b: Uint8Array): string {353 let s = "";354 for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");355 return s;356}357358function hexToBytes(hex: string): Uint8Array {359 if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex");360 const out = new Uint8Array(hex.length / 2);361 for (let i = 0; i < out.length; i++) {362 const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16);363 if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex");364 out[i] = b;365 }366 return out;367}