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 (typeof r.deviceId !== "string" || !r.deviceId) {255 throw new DeviceManifestError("malformed", "revocation.deviceId missing");256 }257 if (258 typeof r.longTermPublicKeyHex !== "string" ||259 !/^[0-9a-fA-F]{64}$/.test(r.longTermPublicKeyHex)260 ) {261 throw new DeviceManifestError(262 "malformed",263 "revocation.longTermPublicKeyHex invalid",264 );265 }266 if (typeof r.revokedAt !== "number" || !Number.isFinite(r.revokedAt)) {267 throw new DeviceManifestError("malformed", "revocation.revokedAt invalid");268 }269 if (typeof r.revokedByDeviceId !== "string" || !r.revokedByDeviceId) {270 throw new DeviceManifestError(271 "malformed",272 "revocation.revokedByDeviceId missing",273 );274 }275 if (276 r.recordVersion != null &&277 r.recordVersion !== DEVICE_REVOCATION_RECORD_VERSION278 ) {279 throw new DeviceManifestError(280 "malformed",281 `revocation.recordVersion unsupported (${r.recordVersion})`,282 );283 }284 }285 return manifest.body;286}287288function normaliseRevocation(r: DeviceRevocation): DeviceRevocation {289 const normalised: DeviceRevocation = {290 deviceId: r.deviceId,291 longTermPublicKeyHex: r.longTermPublicKeyHex.toLowerCase(),292 revokedAt: r.revokedAt,293 revokedByDeviceId: r.revokedByDeviceId,294 recordVersion: r.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,295 };296 if (r.signatureHex != null) normalised.signatureHex = r.signatureHex.toLowerCase();297 return normalised;298}299300function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {301 const normalised: ManifestDeviceEntry = {302 deviceId: d.deviceId,303 deviceName: capDeviceName(d.deviceName),304 deviceType: d.deviceType,305 longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(),306 signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),307 pairedAt: d.pairedAt,308 vouchDepth: d.vouchDepth ?? 0,309 };310 if (d.wsPort != null) normalised.wsPort = d.wsPort;311 if (d.relayUrl != null) normalised.relayUrl = normalizeRelayUrl(d.relayUrl);312 return normalised;313}314315316317318319320321export function canonicalise(value: unknown): string {322 if (value === null) return "null";323 if (typeof value === "number") {324 if (!Number.isFinite(value)) throw new Error("non-finite number");325 return Number.isInteger(value) ? value.toString() : value.toString();326 }327 if (typeof value === "string") return JSON.stringify(value);328 if (typeof value === "boolean") return value ? "true" : "false";329 if (Array.isArray(value)) {330 return "[" + value.map((v) => canonicalise(v)).join(",") + "]";331 }332 if (typeof value === "object") {333 const obj = value as Record<string, unknown>;334 const keys = Object.keys(obj).sort();335 336 337 const present = keys.filter((k) => obj[k] !== null);338 return (339 "{" +340 present.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +341 "}"342 );343 }344 throw new Error(`cannot canonicalise ${typeof value}`);345}346347function bytesToHex(b: Uint8Array): string {348 let s = "";349 for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");350 return s;351}352353function hexToBytes(hex: string): Uint8Array {354 if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex");355 const out = new Uint8Array(hex.length / 2);356 for (let i = 0; i < out.length; i++) {357 const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16);358 if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex");359 out[i] = b;360 }361 return out;362}