From 86abbfa0087f96b5dddcfaf8974ea72d57d131fb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 11 Sep 2026 22:40:10 +0100 Subject: [PATCH 1/2] feat(core): add an in-memory RevocationCheck implementation RevocationCheck had no implementation anywhere in the package -- only a hardcoded always-false stub in web-console. createRevocationView returns one keyed by (token-id, issuer) per management.cddl's contract: an entry counts against a token only when both match, so a third party's entry for someone else's token-id is recorded (it is a well-formed, self-certifying entry on its own terms) but never matches a lookup for the token it does not actually govern. record() runs each entry through verifyRevocationEntry first and drops anything that fails rather than storing it. Extracted bytesToHex out of deviceIdToHex since token-id is an arbitrary-length bstr per tokens.cddl, not a 32-byte device-id, so keying the view's map needs a generic byte-string hex encoder rather than a device-id-shaped one. --- ts/packages/core/src/domain/device-id.ts | 11 +- .../core/src/domain/revocation-view.ts | 42 ++++++ ts/packages/core/test/revocation-view.test.ts | 138 ++++++++++++++++++ 3 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 ts/packages/core/src/domain/revocation-view.ts create mode 100644 ts/packages/core/test/revocation-view.test.ts diff --git a/ts/packages/core/src/domain/device-id.ts b/ts/packages/core/src/domain/device-id.ts index 1c330f5..cb24413 100644 --- a/ts/packages/core/src/domain/device-id.ts +++ b/ts/packages/core/src/domain/device-id.ts @@ -6,15 +6,20 @@ const HEX_RADIX = 16; const HEX_BYTE_WIDTH = 2; const DEVICE_ID_HEX_LENGTH = 64; // 32 bytes, hex-encoded -/** Lowercase, byte-exact hex -- the same encoding room.cddl's device-id-hex regex and the conformance vectors' synthetic device-ids already use. */ -export function deviceIdToHex(device: DeviceId): string { +/** Lowercase, byte-exact hex for an arbitrary-length byte string -- the same encoding convention deviceIdToHex uses for the fixed-length device-id case, extracted so any other byte string needing a stable, displayable, map-keyable text form (e.g. a token-id, which tokens.cddl defines as an arbitrary-length bstr rather than a 32-byte device-id) can use the identical convention without going through a device-id-shaped function. */ +export function bytesToHex(bytes: Uint8Array): string { let hex = ""; - for (const byte of device) { + for (const byte of bytes) { hex += byte.toString(HEX_RADIX).padStart(HEX_BYTE_WIDTH, "0"); } return hex; } +/** Lowercase, byte-exact hex -- the same encoding room.cddl's device-id-hex regex and the conformance vectors' synthetic device-ids already use. */ +export function deviceIdToHex(device: DeviceId): string { + return bytesToHex(device); +} + /** Parses a lowercase, 64-character device-id-hex string back into the 32-byte DeviceId it encodes. Throws on anything that isn't exactly that shape, rather than silently truncating or zero-padding a malformed input. */ export function deviceIdFromHex(hex: string): DeviceId { if (!/^[0-9a-f]{64}$/.test(hex)) { diff --git a/ts/packages/core/src/domain/revocation-view.ts b/ts/packages/core/src/domain/revocation-view.ts new file mode 100644 index 0000000..d60987f --- /dev/null +++ b/ts/packages/core/src/domain/revocation-view.ts @@ -0,0 +1,42 @@ +import { + verifyRevocationEntry, + type RevocationCheck, + type RevocationEntryVerdict, + type VerifyRevocationEntryOptions, +} from "./tokens.js"; +import { bytesToHex, deviceIdToHex } from "./device-id.js"; +import type { DeviceId, RevocationEntry } from "../generated/protocol.js"; + +/** + * An in-memory RevocationCheck fed by ingested revocation-announce frames. Keyed by (token-id, issuer) per management.cddl's contract: an entry counts against a token only when both match, so a third party's entry for someone else's token-id is stored (it is a well-formed, self-certifying entry) but never matches a lookup for the token it does not actually govern. + */ +export interface RevocationView extends RevocationCheck { + /** Verifies one gossiped revocation-entry via verifyRevocationEntry and, if it verifies, records it. An entry that fails verification is dropped, not stored -- the returned verdict lets a caller log or otherwise report the refusal. */ + record: ( + entry: RevocationEntry, + options: VerifyRevocationEntryOptions, + ) => Promise; +} + +function revocationKey(tokenId: Uint8Array, issuer: DeviceId): string { + return `${bytesToHex(tokenId)}:${deviceIdToHex(issuer)}`; +} + +export function createRevocationView(): RevocationView { + const revoked = new Set(); + + return { + async isRevoked(tokenId, issuer) { + return Promise.resolve(revoked.has(revocationKey(tokenId, issuer))); + }, + async record(entry, options) { + const verdict = await verifyRevocationEntry(entry, options); + if (verdict.ok) { + revoked.add( + revocationKey(verdict.claims["token-id"], verdict.claims.issuer), + ); + } + return verdict; + }, + }; +} diff --git a/ts/packages/core/test/revocation-view.test.ts b/ts/packages/core/test/revocation-view.test.ts new file mode 100644 index 0000000..32422e7 --- /dev/null +++ b/ts/packages/core/test/revocation-view.test.ts @@ -0,0 +1,138 @@ +import { webcrypto } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { createNodeIdentity } from "../src/adapters/node-identity.js"; +import { mintRevocationEntry } from "../src/domain/tokens.js"; +import { createRevocationView } from "../src/domain/revocation-view.js"; +import type { IdentityPort } from "../src/ports/identity.js"; + +const ES256 = -7; +const LOW_BYTE_MASK = 0xff; // XOR operand keeping the corrupted byte within one octet when tampering with a signature in this test + +function buf(bytes: Uint8Array | ArrayLike): Uint8Array { + return Uint8Array.from(bytes); +} + +async function generateEs256Identity(): Promise { + const keyPair = await webcrypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + const publicKeyBytes = new Uint8Array( + await webcrypto.subtle.exportKey("raw", keyPair.publicKey), + ); + return createNodeIdentity(keyPair.privateKey, publicKeyBytes, ES256); +} + +describe("createRevocationView", () => { + it("reports nothing revoked before any entry has been recorded", async () => { + const view = createRevocationView(); + const issuer = await generateEs256Identity(); + const revoked = await view.isRevoked(buf([1]), issuer.deviceId); + expect(revoked).toBe(false); + }); + + it("reports a token revoked once its issuer's own revocation-entry has been recorded", async () => { + const view = createRevocationView(); + const issuer = await generateEs256Identity(); + const tokenId = buf([1]); + const entry = await mintRevocationEntry({ + identity: issuer, + tokenId, + revokedAt: 1000, + }); + + const recordVerdict = await view.record(entry, { identity: issuer }); + expect(recordVerdict.ok).toBe(true); + + const revoked = await view.isRevoked(tokenId, issuer.deviceId); + expect(revoked).toBe(true); + }); + + it("does not treat a third party's revocation-entry as revoking someone else's token with the same token-id", async () => { + const view = createRevocationView(); + const actualIssuer = await generateEs256Identity(); + const impostor = await generateEs256Identity(); + const tokenId = buf([1]); + const entry = await mintRevocationEntry({ + identity: impostor, + tokenId, + revokedAt: 1000, + }); + + const recordVerdict = await view.record(entry, { identity: impostor }); + expect(recordVerdict.ok).toBe(true); + + const revoked = await view.isRevoked(tokenId, actualIssuer.deviceId); + expect(revoked).toBe(false); + }); + + it("does not treat a revocation-entry for a different token-id as revoking this one", async () => { + const view = createRevocationView(); + const issuer = await generateEs256Identity(); + const entry = await mintRevocationEntry({ + identity: issuer, + tokenId: buf([1]), + revokedAt: 1000, + }); + + await view.record(entry, { identity: issuer }); + + const revoked = await view.isRevoked(buf([2]), issuer.deviceId); + expect(revoked).toBe(false); + }); + + it("refuses to record an entry whose signature does not verify, and does not let it revoke anything", async () => { + const view = createRevocationView(); + const issuer = await generateEs256Identity(); + const tokenId = buf([1]); + const entry = await mintRevocationEntry({ + identity: issuer, + tokenId, + revokedAt: 1000, + }); + const [protectedHeader, unprotectedHeader, payload, signature] = entry; + const tamperedSignature = buf(signature); + tamperedSignature[0] = (tamperedSignature[0] ?? 0) ^ LOW_BYTE_MASK; + const tamperedEntry: typeof entry = [ + protectedHeader, + unprotectedHeader, + payload, + tamperedSignature, + ]; + + const recordVerdict = await view.record(tamperedEntry, { + identity: issuer, + }); + expect(recordVerdict.ok).toBe(false); + + const revoked = await view.isRevoked(tokenId, issuer.deviceId); + expect(revoked).toBe(false); + }); + + it("keeps recording additional entries independently, so an earlier one is not overwritten", async () => { + const view = createRevocationView(); + const issuer = await generateEs256Identity(); + const firstTokenId = buf([1]); + const secondTokenId = buf([2]); + await view.record( + await mintRevocationEntry({ + identity: issuer, + tokenId: firstTokenId, + revokedAt: 1000, + }), + { identity: issuer }, + ); + await view.record( + await mintRevocationEntry({ + identity: issuer, + tokenId: secondTokenId, + revokedAt: 2000, + }), + { identity: issuer }, + ); + + expect(await view.isRevoked(firstTokenId, issuer.deviceId)).toBe(true); + expect(await view.isRevoked(secondTokenId, issuer.deviceId)).toBe(true); + }); +}); From c12f95e6505409447ff0a0ddc6c009e0988f2aff Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 11 Sep 2026 22:40:15 +0100 Subject: [PATCH 2/2] feat(core): give MeshSession a revocation-announce path MeshSession.applyFrame handled relay-data, handshake, gossip, relay-inbound, manage-response and manage-request -- a revocation-announce frame arriving on a session was logged into frameLog and otherwise silently ignored, with no callback or iterator a consumer could subscribe to. There was consequently no way to either send or receive one through MeshSession at all. Added revocationAnnouncements, an async iterable beside incomingManageRequests, flattening a frame's own entries array to one item per entry since each is independently verifiable and independently meaningful regardless of which frame carried it. Added sendRevocationAnnounce beside sendManageRequest, sent directly over the connection since revocation-announce is a gossiped broadcast rather than a request addressed to a specific peer. --- ts/packages/core/src/domain/mesh-session.ts | 54 ++++++++++++++++++++ ts/packages/core/test/mesh-session.test.ts | 56 +++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/ts/packages/core/src/domain/mesh-session.ts b/ts/packages/core/src/domain/mesh-session.ts index a776d23..cae8e3d 100644 --- a/ts/packages/core/src/domain/mesh-session.ts +++ b/ts/packages/core/src/domain/mesh-session.ts @@ -14,6 +14,8 @@ import { type ManageResponseFrame, type PeerAdvert, type ProtocolVersion, + type RevocationAnnounceFrame, + type RevocationEntry, } from "../generated/protocol.js"; import { SUPPORTED_PROTOCOL_VERSION, negotiate } from "./handshake.js"; import { deviceIdToHex } from "./device-id.js"; @@ -83,10 +85,16 @@ export interface MeshSession { readonly events: AsyncIterable; /** Every `manage-request` received from the peer, in arrival order. */ readonly incomingManageRequests: AsyncIterable; + /** Every `revocation-entry` received from the peer, in arrival order -- a `revocation-announce` frame's own `entries` array is flattened to one item per entry, since each entry is independently verifiable and independently meaningful regardless of which frame carried it. A consumer typically feeds each one into a RevocationView's own `record`. */ + readonly revocationAnnouncements: AsyncIterable; connect: (address: string, localDomains: readonly string[]) => Promise; sendPing: () => Promise; /** Attaches this token to every `manage-request` sent from now on. */ setToken: (token: CapabilityToken) => void; + /** Announces one or more already-minted revocation-entries to the peer. Sent directly over the connection, never relay-wrapped -- revocation-announce is a gossiped broadcast, not a request addressed to a specific peer, so it has no targetDevice/token parameters the way sendManageRequest does. */ + sendRevocationAnnounce: ( + entries: readonly RevocationEntry[], + ) => Promise; /** Sends a manage-request and resolves with the matching manage-response's outcome, correlated by request-id. When targetDevice is given, the request is routed to that specific peer via an established relay-connect pairing (wrapped as relay-data) rather than sent directly over this session's own Connection -- relay-hub deliberately drops manage-request/manage-response frames sent to it directly, since routing between two connected peers is not the relay role's business, so a specific peer reachable only through a relay hub can only be addressed this way. Absent, this sends directly over the Connection exactly as before. When token is given, it is attached to this one request instead of whatever setToken last set -- a single session routinely needs a different token per request when its peer shares more than one scope with this side (e.g. several core/room memberships over one connection), and a session-global token can only ever be correct for one of them. Absent, this request carries setToken's own session-global token exactly as before. */ sendManageRequest: ( command: ManageCommand, @@ -150,6 +158,8 @@ function createSessionCore( >(); const incomingWaiters: ((request: IncomingManageRequest) => void)[] = []; const incomingBacklog: IncomingManageRequest[] = []; + const revocationWaiters: ((entry: RevocationEntry) => void)[] = []; + const revocationBacklog: RevocationEntry[] = []; function snapshot(): SessionEvent { return { @@ -178,6 +188,15 @@ function createSessionCore( } } + function emitRevocationEntry(entry: RevocationEntry): void { + const waiter = revocationWaiters.shift(); + if (waiter) { + waiter(entry); + } else { + revocationBacklog.push(entry); + } + } + function rejectPendingManageRequests(reason: string): void { for (const pending of pendingManageRequests.values()) { pending.reject(new Error(reason)); @@ -292,6 +311,10 @@ function createSessionCore( applyManageResponse(frame); } else if (frame.type === "manage-request") { applyManageRequest(frame, false); + } else if (frame.type === "revocation-announce") { + for (const entry of frame.entries) { + emitRevocationEntry(entry); + } } } @@ -501,6 +524,23 @@ function createSessionCore( }; }, }, + revocationAnnouncements: { + [Symbol.asyncIterator]() { + return { + next: async (): Promise> => + new Promise((resolve) => { + const backlogEntry = revocationBacklog.shift(); + if (backlogEntry) { + resolve({ value: backlogEntry, done: false }); + } else { + revocationWaiters.push((entry) => { + resolve({ value: entry, done: false }); + }); + } + }), + }; + }, + }, async connect(address, localDomains): Promise { if (connection !== null) { throw new Error( @@ -543,6 +583,20 @@ function createSessionCore( emit(); return outcome; }, + async sendRevocationAnnounce( + entries: readonly RevocationEntry[], + ): Promise { + if (connection === null || state.status !== "connected") { + throw new Error("not connected"); + } + const frame: RevocationAnnounceFrame = { + type: "revocation-announce", + entries: [...entries], + }; + frameLog.push({ direction: "sent", frame }); + await transmit(frame, false); + emit(); + }, async close(): Promise { feedCancelled = true; if (handshakeTimer !== null) { diff --git a/ts/packages/core/test/mesh-session.test.ts b/ts/packages/core/test/mesh-session.test.ts index 23274a0..e9364fc 100644 --- a/ts/packages/core/test/mesh-session.test.ts +++ b/ts/packages/core/test/mesh-session.test.ts @@ -9,6 +9,8 @@ import type { ManageRequestFrame, ManageResponseFrame, RelayDataFrame, + RevocationAnnounceFrame, + RevocationEntry, } from "../src/generated/protocol.js"; import type { Clock } from "../src/ports/clock.js"; import type { IdentityPort } from "../src/ports/identity.js"; @@ -625,6 +627,60 @@ describe("capability tokens and manage-request plumbing", () => { }); }); +describe("revocation-announce plumbing", () => { + const ENTRY_B_PROTECTED_HEADER_BYTE = 11; + const ENTRY_B_PAYLOAD_BYTE = 12; + const testEntryA: RevocationEntry = [ + new Uint8Array([1]), + {}, + new Uint8Array([2]), + new Uint8Array([TEST_TOKEN_SIGNATURE_BYTE]), + ]; + const testEntryB: RevocationEntry = [ + new Uint8Array([ENTRY_B_PROTECTED_HEADER_BYTE]), + {}, + new Uint8Array([ENTRY_B_PAYLOAD_BYTE]), + new Uint8Array([TEST_TOKEN_SIGNATURE_BYTE + 1]), + ]; + + it("sends a revocation-announce frame carrying the given entries", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + + await session.sendRevocationAnnounce([testEntryA, testEntryB]); + + const sentFrame = connection.sent.at(-1) as RevocationAnnounceFrame; + expect(sentFrame).toEqual({ + type: "revocation-announce", + entries: [testEntryA, testEntryB], + } satisfies RevocationAnnounceFrame); + await session.close(); + }); + + it("flattens an incoming revocation-announce frame's entries onto revocationAnnouncements, one item per entry", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + + const received: RevocationEntry[] = []; + const receivedBoth = (async (): Promise => { + const iterator = session.revocationAnnouncements[Symbol.asyncIterator](); + received.push((await iterator.next()).value as RevocationEntry); + received.push((await iterator.next()).value as RevocationEntry); + })(); + + connection.push({ + type: "revocation-announce", + entries: [testEntryA, testEntryB], + } satisfies RevocationAnnounceFrame); + + await receivedBoth; + expect(received).toEqual([testEntryA, testEntryB]); + await session.close(); + }); +}); + describe("relay routing", () => { const testCommand: ManageCommand = { verb: "exec:proc",