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}8283export function canonicalDeviceRevocation(record: DeviceRevocation): string {84 return canonicalise({85 deviceId: record.deviceId,86 longTermPublicKeyHex: record.longTermPublicKeyHex.toLowerCase(),87 recordVersion: record.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,88 revokedAt: record.revokedAt,89 revokedByDeviceId: record.revokedByDeviceId,90 });91}9293export async function signDeviceRevocation(94 record: DeviceRevocation,95 signingPrivateKey: CryptoKey,96): Promise<DeviceRevocation> {97 const unsigned: DeviceRevocation = {98 ...record,99 longTermPublicKeyHex: record.longTermPublicKeyHex.toLowerCase(),100 recordVersion: record.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,101 };102 const signature = await signEd25519(103 signingPrivateKey,104 new TextEncoder().encode(canonicalDeviceRevocation(unsigned)),105 );106 return { ...unsigned, signatureHex: bytesToHex(signature) };107}108109export async function verifyDeviceRevocation(110 record: DeviceRevocation,111 issuerSigningPubkey: Uint8Array,112): Promise<boolean> {113 if (!record.signatureHex || !/^[0-9a-f]{128}$/i.test(record.signatureHex)) return false;114 try {115 return await verifyEd25519(116 issuerSigningPubkey,117 new TextEncoder().encode(canonicalDeviceRevocation(record)),118 hexToBytes(record.signatureHex),119 );120 } catch {121 return false;122 }123}124125126127128129130131132export async function verifyDeviceManifest(133 manifest: SignedDeviceManifest,134 issuerSigningPubkey: Uint8Array,135 options: { now?: number; freshnessMs?: number } = {},136): Promise<DeviceManifestBody> {137 if (!manifest || typeof manifest !== "object") {138 throw new DeviceManifestError("malformed", "manifest is not an object");139 }140 if (!manifest.body || !manifest.signatureHex) {141 throw new DeviceManifestError("malformed", "manifest missing body or signature");142 }143 if (manifest.body.manifestVersion !== DEVICE_MANIFEST_VERSION) {144 throw new DeviceManifestError(145 "version",146 `unsupported manifest version ${manifest.body.manifestVersion}`,147 );148 }149 const now = options.now ?? Date.now();150 const freshness = options.freshnessMs ?? DEVICE_MANIFEST_FRESHNESS_MS;151 if (152 typeof manifest.body.issuedAt !== "number" ||153 Math.abs(now - manifest.body.issuedAt) > freshness154 ) {155 throw new DeviceManifestError(156 "freshness",157 `manifest issuedAt ${manifest.body.issuedAt} outside ±${freshness}ms window`,158 );159 }160161 const signature = hexToBytes(manifest.signatureHex);162 if (signature.length !== 64) {163 throw new DeviceManifestError("signature", "signature must be 64 bytes");164 }165 const canonical = canonicalise(manifest.body);166 const ok = await verifyEd25519(167 issuerSigningPubkey,168 new TextEncoder().encode(canonical),169 signature,170 );171 if (!ok) throw new DeviceManifestError("signature", "Ed25519 signature did not verify");172173 174 175 176 if (!Array.isArray(manifest.body.devices)) {177 throw new DeviceManifestError("malformed", "devices is not an array");178 }179 for (const d of manifest.body.devices) {180 if (typeof d.deviceId !== "string" || !d.deviceId) {181 throw new DeviceManifestError("malformed", "device.deviceId missing");182 }183 if (typeof d.deviceName !== "string" || !d.deviceName) {184 throw new DeviceManifestError("malformed", "device.deviceName missing");185 }186 if (187 d.deviceType !== "extension" &&188 d.deviceType !== "android" &&189 d.deviceType !== "ios"190 ) {191 throw new DeviceManifestError("malformed", "device.deviceType invalid");192 }193 if (typeof d.longTermPublicKeyHex !== "string" || d.longTermPublicKeyHex.length !== 64) {194 throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid");195 }196 if (typeof d.signingPublicKeyHex !== "string" || d.signingPublicKeyHex.length !== 64) {197 throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");198 }199 if (d.vouchDepth != null && (!Number.isInteger(d.vouchDepth) || d.vouchDepth < 0 || d.vouchDepth > MAX_VOUCH_DEPTH)) {200 throw new DeviceManifestError("malformed", "device.vouchDepth invalid");201 }202 if (203 d.wsPort != null &&204 (205 typeof d.wsPort !== "number" ||206 !Number.isInteger(d.wsPort) ||207 d.wsPort < 0 ||208 d.wsPort > 65535209 )210 ) {211 throw new DeviceManifestError("malformed", "device.wsPort invalid");212 }213 if (d.relayUrl != null) {214 try {215 normalizeRelayUrl(d.relayUrl);216 } catch {217 throw new DeviceManifestError("malformed", "device.relayUrl invalid");218 }219 }220 }221222 if (!Array.isArray(manifest.body.revoked)) {223 throw new DeviceManifestError("malformed", "revoked is not an array");224 }225 for (const r of manifest.body.revoked) {226 if (typeof r.deviceId !== "string" || !r.deviceId) {227 throw new DeviceManifestError("malformed", "revocation.deviceId missing");228 }229 if (230 typeof r.longTermPublicKeyHex !== "string" ||231 !/^[0-9a-fA-F]{64}$/.test(r.longTermPublicKeyHex)232 ) {233 throw new DeviceManifestError(234 "malformed",235 "revocation.longTermPublicKeyHex invalid",236 );237 }238 if (typeof r.revokedAt !== "number" || !Number.isFinite(r.revokedAt)) {239 throw new DeviceManifestError("malformed", "revocation.revokedAt invalid");240 }241 if (typeof r.revokedByDeviceId !== "string" || !r.revokedByDeviceId) {242 throw new DeviceManifestError(243 "malformed",244 "revocation.revokedByDeviceId missing",245 );246 }247 if (248 r.recordVersion != null &&249 r.recordVersion !== DEVICE_REVOCATION_RECORD_VERSION250 ) {251 throw new DeviceManifestError(252 "malformed",253 `revocation.recordVersion unsupported (${r.recordVersion})`,254 );255 }256 }257 return manifest.body;258}259260function normaliseRevocation(r: DeviceRevocation): DeviceRevocation {261 const normalised: DeviceRevocation = {262 deviceId: r.deviceId,263 longTermPublicKeyHex: r.longTermPublicKeyHex.toLowerCase(),264 revokedAt: r.revokedAt,265 revokedByDeviceId: r.revokedByDeviceId,266 recordVersion: r.recordVersion ?? DEVICE_REVOCATION_RECORD_VERSION,267 };268 if (r.signatureHex != null) normalised.signatureHex = r.signatureHex.toLowerCase();269 return normalised;270}271272function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {273 const normalised: ManifestDeviceEntry = {274 deviceId: d.deviceId,275 deviceName: capDeviceName(d.deviceName),276 deviceType: d.deviceType,277 longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(),278 signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),279 pairedAt: d.pairedAt,280 vouchDepth: d.vouchDepth ?? 0,281 };282 if (d.wsPort != null) normalised.wsPort = d.wsPort;283 if (d.relayUrl != null) normalised.relayUrl = normalizeRelayUrl(d.relayUrl);284 return normalised;285}286287288289290291292293export function canonicalise(value: unknown): string {294 if (value === null) return "null";295 if (typeof value === "number") {296 if (!Number.isFinite(value)) throw new Error("non-finite number");297 return Number.isInteger(value) ? value.toString() : value.toString();298 }299 if (typeof value === "string") return JSON.stringify(value);300 if (typeof value === "boolean") return value ? "true" : "false";301 if (Array.isArray(value)) {302 return "[" + value.map((v) => canonicalise(v)).join(",") + "]";303 }304 if (typeof value === "object") {305 const obj = value as Record<string, unknown>;306 const keys = Object.keys(obj).sort();307 308 309 const present = keys.filter((k) => obj[k] !== null);310 return (311 "{" +312 present.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +313 "}"314 );315 }316 throw new Error(`cannot canonicalise ${typeof value}`);317}318319function bytesToHex(b: Uint8Array): string {320 let s = "";321 for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");322 return s;323}324325function hexToBytes(hex: string): Uint8Array {326 if (hex.length % 2 !== 0) throw new DeviceManifestError("malformed", "odd-length hex");327 const out = new Uint8Array(hex.length / 2);328 for (let i = 0; i < out.length; i++) {329 const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16);330 if (Number.isNaN(b)) throw new DeviceManifestError("malformed", "bad hex");331 out[i] = b;332 }333 return out;334}