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/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/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/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", 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); + }); +});