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