From a9c8e421b92ef6890e819bfd9f332d445906d388 Mon Sep 17 00:00:00 2001 From: Lokesh Date: Sat, 12 Sep 2026 17:29:34 +0400 Subject: [PATCH 01/16] feat(protocol): define opaque relay transport contracts Signed-off-by: Lokesh --- packages/protocol/README.md | 3 +- packages/protocol/src/index.ts | 1 + packages/protocol/src/remote-transport.ts | 658 ++++++++++++++++++ .../test/fixtures/internal-relay-api-v1.json | 39 ++ .../test/fixtures/remote-transport-v1.json | 70 ++ .../protocol/test/remote-transport.test.ts | 167 +++++ .../test/support/fake-remote-crypto.ts | 66 ++ 7 files changed, 1003 insertions(+), 1 deletion(-) create mode 100644 packages/protocol/src/remote-transport.ts create mode 100644 packages/protocol/test/fixtures/internal-relay-api-v1.json create mode 100644 packages/protocol/test/fixtures/remote-transport-v1.json create mode 100644 packages/protocol/test/remote-transport.test.ts create mode 100644 packages/protocol/test/support/fake-remote-crypto.ts diff --git a/packages/protocol/README.md b/packages/protocol/README.md index 3adc6e75..2c8f890b 100644 --- a/packages/protocol/README.md +++ b/packages/protocol/README.md @@ -1,6 +1,7 @@ + # `@axl/protocol` -This dependency-free package defines Axl's versioned JSONL events, model stream messages, and local wire protocol. The current wire format covers session creation, listing, paged history, resume, fork, clone, rename, deletion, import, export, catalog invalidation, subscriptions, turns, steering, follow-ups, interruption, reload, live activity, abortable blob transport, workspace review, extension interactions, and model, thinking, and web-tool configuration. Runtime parsers validate every value received from an untrusted boundary. +This dependency-free package defines Axl's versioned JSONL events, model stream messages, local wire protocol, and opaque remote-transport framing. The current local wire format covers session creation, listing, paged history, resume, fork, clone, rename, deletion, import, export, catalog invalidation, subscriptions, turns, steering, follow-ups, interruption, reload, live activity, abortable blob transport, workspace review, extension interactions, and model, thinking, and web-tool configuration. Remote transport contracts define routing identifiers, limits, tickets, receipts, delivery states, and bounded binary frames without defining or implementing cryptography. Runtime parsers validate every value received from an untrusted boundary. diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 60892b73..23450afd 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -8,6 +8,7 @@ export * from "./event-envelope.ts"; export * from "./events.ts"; export * from "./model-stream.ts"; export * from "./provider-management.ts"; +export * from "./remote-transport.ts"; export * from "./version.ts"; export * from "./wire.ts"; export * from "./host-control.ts"; diff --git a/packages/protocol/src/remote-transport.ts b/packages/protocol/src/remote-transport.ts new file mode 100644 index 00000000..1af9804a --- /dev/null +++ b/packages/protocol/src/remote-transport.ts @@ -0,0 +1,658 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import { ProtocolValidationError } from "./event-envelope.ts"; + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const base64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; +const methodPattern = /^[a-z][a-z0-9]*(?:[._-][a-zA-Z0-9]+)*$/; +const frameMagic = Uint8Array.of(0x41, 0x58, 0x4c, 0x52); +const routedFrameHeaderBytes = 42; +const shortFrameBytes = 23; + +declare const installationIdBrand: unique symbol; +declare const deviceIdBrand: unique symbol; +declare const cryptoSessionIdBrand: unique symbol; +declare const axlSessionIdBrand: unique symbol; +declare const routeIdBrand: unique symbol; +declare const transportAttemptIdBrand: unique symbol; +declare const envelopeIdBrand: unique symbol; +declare const requestIdBrand: unique symbol; +declare const idempotencyKeyBrand: unique symbol; +declare const objectIdBrand: unique symbol; + +type Nominal = string & { readonly [Key in Brand]: true }; + +export type InstallationId = Nominal; +export type DeviceId = Nominal; +export type CryptoSessionId = Nominal; +export type AxlSessionId = Nominal; +export type RouteId = Nominal; +export type TransportAttemptId = Nominal; +export type EnvelopeId = Nominal; +export type RequestId = Nominal; +export type IdempotencyKey = Nominal; +export type ObjectId = Nominal; + +export const REMOTE_TRANSPORT_VERSION = 1 as const; +export const INTERNAL_RELAY_API_VERSION = 1 as const; +export const MAX_RELAY_FRAME_BYTES = 65_535; +export const MAX_RELAY_QUEUED_BYTES = 512 * 1024; +export const RELAY_HEARTBEAT_INTERVAL_MS = 20_000; +export const RELAY_IDLE_TIMEOUT_MS = 60_000; +export const RELAY_TICKET_LIFETIME_MS = 60_000; +export const MAX_RELAY_OPAQUE_PAYLOAD_BYTES = MAX_RELAY_FRAME_BYTES - routedFrameHeaderBytes; + +export interface RelayLimits { + readonly maxFrameBytes: number; + readonly maxQueuedBytes: number; + readonly heartbeatIntervalMs: number; + readonly idleTimeoutMs: number; +} + +export const DEFAULT_RELAY_LIMITS: RelayLimits = Object.freeze({ + maxFrameBytes: MAX_RELAY_FRAME_BYTES, + maxQueuedBytes: MAX_RELAY_QUEUED_BYTES, + heartbeatIntervalMs: RELAY_HEARTBEAT_INTERVAL_MS, + idleTimeoutMs: RELAY_IDLE_TIMEOUT_MS, +}); + +export interface IssueRelayTicketRequest { + readonly installationId: InstallationId; + readonly deviceId?: DeviceId; + readonly role: "daemon" | "device"; +} + +export interface IssueRelayTicketResult { + readonly ticket: string; + readonly relayUrl: string; + readonly expiresAt: number; + readonly proofSchemeVersion: number; + readonly limits: RelayLimits; +} + +export interface ConsumeRelayTicketRequest { + readonly ticket: string; + readonly relayInstanceId: string; + readonly connectionNonce: string; + readonly possessionProof: Uint8Array; +} + +export interface ConsumeRelayTicketResult { + readonly installationId: InstallationId; + readonly deviceId?: DeviceId; + readonly sourceRouteId: RouteId; + readonly role: "daemon" | "device"; + readonly leaseExpiresAt: number; + readonly limits: RelayLimits; +} + +export interface RelaySendFrame { + readonly transportVersion: typeof REMOTE_TRANSPORT_VERSION; + readonly attemptId: TransportAttemptId; + readonly destinationRouteId: RouteId; + readonly opaquePayload: Uint8Array; +} + +export interface RelayDelivery { + readonly transportVersion: typeof REMOTE_TRANSPORT_VERSION; + readonly attemptId: TransportAttemptId; + readonly sourceRouteId: RouteId; + readonly opaquePayload: Uint8Array; +} + +export type RelayReceiptStatus = "admitted" | "forwarded"; + +export interface RelayReceipt { + readonly transportVersion: typeof REMOTE_TRANSPORT_VERSION; + readonly attemptId: TransportAttemptId; + readonly status: RelayReceiptStatus; +} + +export const RELAY_FAILURE_CODES = [ + "bad_frame", + "unsupported_transport_version", + "unauthorized", + "forbidden_route", + "ticket_expired", + "ticket_consumed", + "destination_offline", + "rate_limited", + "queue_full", + "slow_consumer", + "service_unavailable", +] as const; + +export type RelayFailureCode = (typeof RELAY_FAILURE_CODES)[number]; + +export interface RelayFailure { + readonly transportVersion: typeof REMOTE_TRANSPORT_VERSION; + readonly attemptId: TransportAttemptId; + readonly code: RelayFailureCode; +} + +export type RelayBinaryFrame = RelaySendFrame | RelayDelivery | RelayReceipt | RelayFailure; + +export interface AuthenticatedRemoteRequest { + readonly deviceId: DeviceId; + readonly requestId: RequestId; + readonly idempotencyKey?: IdempotencyKey; + readonly method: string; + readonly params: unknown; +} + +export type RemoteDeliveryState = + | "queued_local" + | "sending" + | "relay_admitted" + | "relay_forwarded" + | "daemon_accepted" + | "operation_running" + | "completed" + | "failed"; + +export interface OpaqueOutboxRecord { + readonly requestId: RequestId; + readonly idempotencyKey: IdempotencyKey; + readonly destinationRouteId: RouteId; + readonly opaqueEnvelope: Uint8Array; + readonly createdAt: number; + readonly state: "queued_local" | "sending" | "daemon_accepted"; +} + +export interface RelayRevocationNotification { + readonly version: typeof INTERNAL_RELAY_API_VERSION; + readonly installationId: InstallationId; + readonly deviceId?: DeviceId; + readonly generation: number; + readonly effectiveAt: number; +} + +export interface RelayRevocationResult { + readonly version: typeof INTERNAL_RELAY_API_VERSION; + readonly accepted: true; +} + +export type InternalConsumeRelayTicketWireRequest = Omit< + ConsumeRelayTicketRequest, + "possessionProof" +> & { + readonly version: typeof INTERNAL_RELAY_API_VERSION; + readonly possessionProof: string; +}; + +export type InternalConsumeRelayTicketWireResult = ConsumeRelayTicketResult & { + readonly version: typeof INTERNAL_RELAY_API_VERSION; +}; + +function fail(path: string, message: string): never { + throw new ProtocolValidationError(path, message); +} + +function object(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + fail(path, "must be an object"); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) fail(path, "must be a plain object"); + return value as Record; +} + +function exact( + value: Record, + path: string, + required: readonly string[], + optional: readonly string[] = [], +): void { + const allowed = new Set([...required, ...optional]); + for (const key of Object.keys(value)) if (!allowed.has(key)) fail(`${path}.${key}`, "is unknown"); + for (const key of required) if (!(key in value)) fail(`${path}.${key}`, "is required"); +} + +function boundedString(value: unknown, path: string, maximum: number): string { + if (typeof value !== "string" || value.length === 0 || value.length > maximum) { + fail(path, `must be a non-empty string no longer than ${maximum} characters`); + } + return value; +} + +function integer(value: unknown, path: string, minimum: number, maximum: number): number { + if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) { + fail(path, `must be an integer from ${minimum} through ${maximum}`); + } + return value as number; +} + +function timestamp(value: unknown, path: string): number { + return integer(value, path, 0, Number.MAX_SAFE_INTEGER); +} + +function role(value: unknown, path: string): "daemon" | "device" { + if (value !== "daemon" && value !== "device") fail(path, "must be daemon or device"); + return value; +} + +function uuid(value: unknown, path: string): Nominal { + if (typeof value !== "string" || !uuidPattern.test(value)) { + fail(path, "must be a lowercase RFC 9562 UUID"); + } + return value as Nominal; +} + +export function parseInstallationId(value: unknown, path = "installationId"): InstallationId { + return uuid(value, path); +} + +export function parseDeviceId(value: unknown, path = "deviceId"): DeviceId { + return uuid(value, path); +} + +export function parseCryptoSessionId(value: unknown, path = "cryptoSessionId"): CryptoSessionId { + return uuid(value, path); +} + +export function parseAxlSessionId(value: unknown, path = "axlSessionId"): AxlSessionId { + return uuid(value, path); +} + +export function parseRouteId(value: unknown, path = "routeId"): RouteId { + return uuid(value, path); +} + +export function parseTransportAttemptId(value: unknown, path = "attemptId"): TransportAttemptId { + return uuid(value, path); +} + +export function parseEnvelopeId(value: unknown, path = "envelopeId"): EnvelopeId { + return uuid(value, path); +} + +export function parseRemoteRequestId(value: unknown, path = "requestId"): RequestId { + return uuid(value, path); +} + +export function parseIdempotencyKey(value: unknown, path = "idempotencyKey"): IdempotencyKey { + return uuid(value, path); +} + +export function parseObjectId(value: unknown, path = "objectId"): ObjectId { + return uuid(value, path); +} + +export function parseRelayLimits(value: unknown, path = "limits"): RelayLimits { + const candidate = object(value, path); + exact(candidate, path, [ + "maxFrameBytes", + "maxQueuedBytes", + "heartbeatIntervalMs", + "idleTimeoutMs", + ]); + return { + maxFrameBytes: integer( + candidate.maxFrameBytes, + `${path}.maxFrameBytes`, + 1, + MAX_RELAY_FRAME_BYTES, + ), + maxQueuedBytes: integer( + candidate.maxQueuedBytes, + `${path}.maxQueuedBytes`, + 1, + MAX_RELAY_QUEUED_BYTES, + ), + heartbeatIntervalMs: integer( + candidate.heartbeatIntervalMs, + `${path}.heartbeatIntervalMs`, + 1, + 300_000, + ), + idleTimeoutMs: integer(candidate.idleTimeoutMs, `${path}.idleTimeoutMs`, 1, 600_000), + }; +} + +export function parseIssueRelayTicketRequest(value: unknown): IssueRelayTicketRequest { + const candidate = object(value, "request"); + exact(candidate, "request", ["installationId", "role"], ["deviceId"]); + const parsedRole = role(candidate.role, "request.role"); + const deviceId = + candidate.deviceId === undefined + ? undefined + : parseDeviceId(candidate.deviceId, "request.deviceId"); + if (parsedRole === "device" && deviceId === undefined) { + fail("request.deviceId", "is required for the device role"); + } + if (parsedRole === "daemon" && deviceId !== undefined) { + fail("request.deviceId", "is not allowed for the daemon role"); + } + return { + installationId: parseInstallationId(candidate.installationId, "request.installationId"), + ...(deviceId === undefined ? {} : { deviceId }), + role: parsedRole, + }; +} + +export function parseIssueRelayTicketResult(value: unknown): IssueRelayTicketResult { + const candidate = object(value, "result"); + exact(candidate, "result", ["ticket", "relayUrl", "expiresAt", "proofSchemeVersion", "limits"]); + const relayUrl = boundedString(candidate.relayUrl, "result.relayUrl", 2_048); + let parsedUrl: URL; + try { + parsedUrl = new URL(relayUrl); + } catch { + fail("result.relayUrl", "must be an absolute URL"); + } + if (parsedUrl.protocol !== "wss:") { + fail("result.relayUrl", "must use wss"); + } + return { + ticket: boundedString(candidate.ticket, "result.ticket", 1_024), + relayUrl, + expiresAt: timestamp(candidate.expiresAt, "result.expiresAt"), + proofSchemeVersion: integer(candidate.proofSchemeVersion, "result.proofSchemeVersion", 1, 255), + limits: parseRelayLimits(candidate.limits, "result.limits"), + }; +} + +export function encodeBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let encoded = ""; + for (let offset = 0; offset < binary.length; offset += 3) { + const first = binary.charCodeAt(offset); + const hasSecond = offset + 1 < binary.length; + const hasThird = offset + 2 < binary.length; + const second = hasSecond ? binary.charCodeAt(offset + 1) : 0; + const third = hasThird ? binary.charCodeAt(offset + 2) : 0; + const bits = (first << 16) | (second << 8) | third; + encoded += alphabet[(bits >>> 18) & 63]; + encoded += alphabet[(bits >>> 12) & 63]; + encoded += hasSecond ? alphabet[(bits >>> 6) & 63] : "="; + encoded += hasThird ? alphabet[bits & 63] : "="; + } + return encoded; +} + +export function decodeBase64(value: unknown, path: string, maximumBytes: number): Uint8Array { + const encoded = boundedString(value, path, Math.ceil(maximumBytes / 3) * 4); + if (!base64Pattern.test(encoded)) fail(path, "must be canonical base64"); + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const output: number[] = []; + for (let offset = 0; offset < encoded.length; offset += 4) { + const chars = encoded.slice(offset, offset + 4); + const values = [...chars].map((character) => + character === "=" ? 0 : alphabet.indexOf(character), + ); + if (values.some((entry) => entry < 0)) fail(path, "must be canonical base64"); + const [first, second, third, fourth] = values; + if ( + first === undefined || + second === undefined || + third === undefined || + fourth === undefined + ) { + fail(path, "must be canonical base64"); + } + const bits = (first << 18) | (second << 12) | (third << 6) | fourth; + output.push((bits >>> 16) & 0xff); + if (chars[2] !== "=") output.push((bits >>> 8) & 0xff); + if (chars[3] !== "=") output.push(bits & 0xff); + } + if (output.length > maximumBytes || encodeBase64(Uint8Array.from(output)) !== encoded) { + fail(path, `must encode no more than ${maximumBytes} bytes`); + } + return Uint8Array.from(output); +} + +export function parseInternalConsumeRelayTicketRequest(value: unknown): ConsumeRelayTicketRequest { + const candidate = object(value, "request"); + exact(candidate, "request", [ + "version", + "ticket", + "relayInstanceId", + "connectionNonce", + "possessionProof", + ]); + if (candidate.version !== INTERNAL_RELAY_API_VERSION) { + fail("request.version", `must equal ${INTERNAL_RELAY_API_VERSION}`); + } + return { + ticket: boundedString(candidate.ticket, "request.ticket", 1_024), + relayInstanceId: boundedString(candidate.relayInstanceId, "request.relayInstanceId", 128), + connectionNonce: boundedString(candidate.connectionNonce, "request.connectionNonce", 256), + possessionProof: decodeBase64(candidate.possessionProof, "request.possessionProof", 1_024), + }; +} + +export function encodeInternalConsumeRelayTicketRequest( + request: ConsumeRelayTicketRequest, +): InternalConsumeRelayTicketWireRequest { + return { + version: INTERNAL_RELAY_API_VERSION, + ticket: request.ticket, + relayInstanceId: request.relayInstanceId, + connectionNonce: request.connectionNonce, + possessionProof: encodeBase64(request.possessionProof), + }; +} + +export function parseInternalConsumeRelayTicketResult(value: unknown): ConsumeRelayTicketResult { + const candidate = object(value, "result"); + exact( + candidate, + "result", + ["version", "installationId", "sourceRouteId", "role", "leaseExpiresAt", "limits"], + ["deviceId"], + ); + if (candidate.version !== INTERNAL_RELAY_API_VERSION) { + fail("result.version", `must equal ${INTERNAL_RELAY_API_VERSION}`); + } + const parsedRole = role(candidate.role, "result.role"); + const deviceId = + candidate.deviceId === undefined + ? undefined + : parseDeviceId(candidate.deviceId, "result.deviceId"); + if (parsedRole === "device" && deviceId === undefined) fail("result.deviceId", "is required"); + if (parsedRole === "daemon" && deviceId !== undefined) fail("result.deviceId", "is not allowed"); + return { + installationId: parseInstallationId(candidate.installationId, "result.installationId"), + ...(deviceId === undefined ? {} : { deviceId }), + sourceRouteId: parseRouteId(candidate.sourceRouteId, "result.sourceRouteId"), + role: parsedRole, + leaseExpiresAt: timestamp(candidate.leaseExpiresAt, "result.leaseExpiresAt"), + limits: parseRelayLimits(candidate.limits, "result.limits"), + }; +} + +export function encodeInternalConsumeRelayTicketResult( + result: ConsumeRelayTicketResult, +): InternalConsumeRelayTicketWireResult { + return { version: INTERNAL_RELAY_API_VERSION, ...result }; +} + +export function parseRelayRevocationNotification(value: unknown): RelayRevocationNotification { + const candidate = object(value, "request"); + exact( + candidate, + "request", + ["version", "installationId", "generation", "effectiveAt"], + ["deviceId"], + ); + if (candidate.version !== INTERNAL_RELAY_API_VERSION) { + fail("request.version", `must equal ${INTERNAL_RELAY_API_VERSION}`); + } + return { + version: INTERNAL_RELAY_API_VERSION, + installationId: parseInstallationId(candidate.installationId, "request.installationId"), + ...(candidate.deviceId === undefined + ? {} + : { deviceId: parseDeviceId(candidate.deviceId, "request.deviceId") }), + generation: integer(candidate.generation, "request.generation", 1, Number.MAX_SAFE_INTEGER), + effectiveAt: timestamp(candidate.effectiveAt, "request.effectiveAt"), + }; +} + +export function parseRelayRevocationResult(value: unknown): RelayRevocationResult { + const candidate = object(value, "result"); + exact(candidate, "result", ["version", "accepted"]); + if (candidate.version !== INTERNAL_RELAY_API_VERSION) { + fail("result.version", `must equal ${INTERNAL_RELAY_API_VERSION}`); + } + if (candidate.accepted !== true) fail("result.accepted", "must be true"); + return { version: INTERNAL_RELAY_API_VERSION, accepted: true }; +} + +export function parseAuthenticatedRemoteRequest(value: unknown): AuthenticatedRemoteRequest { + const candidate = object(value, "request"); + exact(candidate, "request", ["deviceId", "requestId", "method", "params"], ["idempotencyKey"]); + const method = boundedString(candidate.method, "request.method", 128); + if (!methodPattern.test(method)) fail("request.method", "has an invalid method name"); + return { + deviceId: parseDeviceId(candidate.deviceId, "request.deviceId"), + requestId: parseRemoteRequestId(candidate.requestId, "request.requestId"), + ...(candidate.idempotencyKey === undefined + ? {} + : { + idempotencyKey: parseIdempotencyKey(candidate.idempotencyKey, "request.idempotencyKey"), + }), + method, + params: candidate.params, + }; +} + +function uuidBytes(value: string): Uint8Array { + const hexadecimal = value.replaceAll("-", ""); + return Uint8Array.from({ length: 16 }, (_, index) => + Number.parseInt(hexadecimal.slice(index * 2, index * 2 + 2), 16), + ); +} + +function bytesUuid(bytes: Uint8Array, offset: number, path: string): string { + const hexadecimal = [...bytes.subarray(offset, offset + 16)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + return uuidPattern.test( + `${hexadecimal.slice(0, 8)}-${hexadecimal.slice(8, 12)}-${hexadecimal.slice(12, 16)}-${hexadecimal.slice(16, 20)}-${hexadecimal.slice(20)}`, + ) + ? `${hexadecimal.slice(0, 8)}-${hexadecimal.slice(8, 12)}-${hexadecimal.slice(12, 16)}-${hexadecimal.slice(16, 20)}-${hexadecimal.slice(20)}` + : fail(path, "contains an invalid RFC 9562 UUID"); +} + +function writePrefix(output: Uint8Array, kind: number, attemptId: TransportAttemptId): void { + output.set(frameMagic, 0); + output[4] = REMOTE_TRANSPORT_VERSION; + output[5] = kind; + output.set(uuidBytes(attemptId), 6); +} + +function encodeRoutedFrame( + kind: 1 | 2, + attemptId: TransportAttemptId, + routeId: RouteId, + payload: Uint8Array, +): Uint8Array { + if (payload.byteLength > MAX_RELAY_OPAQUE_PAYLOAD_BYTES) { + fail("frame.opaquePayload", `must not exceed ${MAX_RELAY_OPAQUE_PAYLOAD_BYTES} bytes`); + } + const output = new Uint8Array(routedFrameHeaderBytes + payload.byteLength); + writePrefix(output, kind, attemptId); + output.set(uuidBytes(routeId), 22); + new DataView(output.buffer).setUint32(38, payload.byteLength, false); + output.set(payload, routedFrameHeaderBytes); + return output; +} + +export function encodeRelayBinaryFrame(frame: RelayBinaryFrame): Uint8Array { + switch ( + "destinationRouteId" in frame + ? "send" + : "sourceRouteId" in frame + ? "delivery" + : "status" in frame + ? "receipt" + : "failure" + ) { + case "send": + return encodeRoutedFrame( + 1, + frame.attemptId, + (frame as RelaySendFrame).destinationRouteId, + (frame as RelaySendFrame).opaquePayload, + ); + case "delivery": + return encodeRoutedFrame( + 2, + frame.attemptId, + (frame as RelayDelivery).sourceRouteId, + (frame as RelayDelivery).opaquePayload, + ); + case "receipt": { + const output = new Uint8Array(shortFrameBytes); + writePrefix(output, 3, frame.attemptId); + const status = (frame as RelayReceipt).status; + if (status !== "admitted" && status !== "forwarded") fail("frame.status", "is invalid"); + output[22] = status === "admitted" ? 1 : 2; + return output; + } + case "failure": { + const output = new Uint8Array(shortFrameBytes); + writePrefix(output, 4, frame.attemptId); + const failureIndex = RELAY_FAILURE_CODES.indexOf((frame as RelayFailure).code); + if (failureIndex < 0) fail("frame.code", "is invalid"); + output[22] = failureIndex + 1; + return output; + } + } +} + +export function parseRelayBinaryFrame(value: Uint8Array): RelayBinaryFrame { + if (!(value instanceof Uint8Array)) fail("frame", "must be bytes"); + if (value.byteLength < 6 || value.byteLength > MAX_RELAY_FRAME_BYTES) { + fail("frame", `must contain 6 through ${MAX_RELAY_FRAME_BYTES} bytes`); + } + if (!frameMagic.every((byte, index) => value[index] === byte)) fail("frame.magic", "is invalid"); + if (value[4] !== REMOTE_TRANSPORT_VERSION) { + fail("frame.transportVersion", `must equal ${REMOTE_TRANSPORT_VERSION}`); + } + const kind = value[5]; + const attemptId = parseTransportAttemptId(bytesUuid(value, 6, "frame.attemptId")); + if (kind === 1 || kind === 2) { + if (value.byteLength < routedFrameHeaderBytes) fail("frame", "has a truncated routed header"); + const routeId = parseRouteId(bytesUuid(value, 22, "frame.routeId")); + const payloadLength = new DataView(value.buffer, value.byteOffset, value.byteLength).getUint32( + 38, + false, + ); + if (payloadLength !== value.byteLength - routedFrameHeaderBytes) { + fail("frame.opaquePayload", "length does not match the frame size"); + } + const opaquePayload = value.slice(routedFrameHeaderBytes); + return kind === 1 + ? { + transportVersion: REMOTE_TRANSPORT_VERSION, + attemptId, + destinationRouteId: routeId, + opaquePayload, + } + : { + transportVersion: REMOTE_TRANSPORT_VERSION, + attemptId, + sourceRouteId: routeId, + opaquePayload, + }; + } + if (value.byteLength !== shortFrameBytes) fail("frame", "has an invalid control-frame size"); + if (kind === 3) { + const status = value[22] === 1 ? "admitted" : value[22] === 2 ? "forwarded" : undefined; + if (status === undefined) fail("frame.status", "is invalid"); + return { transportVersion: REMOTE_TRANSPORT_VERSION, attemptId, status }; + } + if (kind === 4) { + const failureByte = value[22]; + if (failureByte === undefined) fail("frame.code", "is missing"); + const code = RELAY_FAILURE_CODES[failureByte - 1]; + if (code === undefined) fail("frame.code", "is invalid"); + return { transportVersion: REMOTE_TRANSPORT_VERSION, attemptId, code }; + } + return fail("frame.kind", "is invalid"); +} diff --git a/packages/protocol/test/fixtures/internal-relay-api-v1.json b/packages/protocol/test/fixtures/internal-relay-api-v1.json new file mode 100644 index 00000000..7a6596de --- /dev/null +++ b/packages/protocol/test/fixtures/internal-relay-api-v1.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "consumeTicket": { + "request": { + "version": 1, + "ticket": "fixture-ticket-never-valid-outside-tests", + "relayInstanceId": "relay-fixture-1", + "connectionNonce": "fixture-connection-nonce", + "possessionProof": "AAECA/8=" + }, + "result": { + "version": 1, + "installationId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "deviceId": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "sourceRouteId": "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + "role": "device", + "leaseExpiresAt": 2000000000000, + "limits": { + "maxFrameBytes": 65535, + "maxQueuedBytes": 524288, + "heartbeatIntervalMs": 20000, + "idleTimeoutMs": 60000 + } + } + }, + "revocation": { + "request": { + "version": 1, + "installationId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "deviceId": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "generation": 7, + "effectiveAt": 1900000000000 + }, + "result": { + "version": 1, + "accepted": true + } + } +} diff --git a/packages/protocol/test/fixtures/remote-transport-v1.json b/packages/protocol/test/fixtures/remote-transport-v1.json new file mode 100644 index 00000000..628d839a --- /dev/null +++ b/packages/protocol/test/fixtures/remote-transport-v1.json @@ -0,0 +1,70 @@ +{ + "version": 1, + "accepted": [ + { + "name": "send", + "base64": "QVhMUgEBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAAAIAAEC/0FYTFI=", + "frame": { + "kind": "send", + "attemptId": "11111111-1111-4111-8111-111111111111", + "routeId": "22222222-2222-4222-8222-222222222222", + "opaquePayloadBase64": "AAEC/0FYTFI=" + } + }, + { + "name": "delivery", + "base64": "QVhMUgECERERERERQRGBERERERERETMzMzMzM0MzgzMzMzMzMzMAAAAIAAEC/0FYTFI=", + "frame": { + "kind": "delivery", + "attemptId": "11111111-1111-4111-8111-111111111111", + "routeId": "33333333-3333-4333-8333-333333333333", + "opaquePayloadBase64": "AAEC/0FYTFI=" + } + }, + { + "name": "admitted-receipt", + "base64": "QVhMUgEDERERERERQRGBEREREREREQE=", + "frame": { + "kind": "receipt", + "attemptId": "11111111-1111-4111-8111-111111111111", + "status": "admitted" + } + }, + { + "name": "destination-offline", + "base64": "QVhMUgEEERERERERQRGBEREREREREQc=", + "frame": { + "kind": "failure", + "attemptId": "11111111-1111-4111-8111-111111111111", + "code": "destination_offline" + } + } + ], + "rejected": [ + { + "name": "wrong-magic", + "base64": "QlhMUgEBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAAAIAAEC/0FYTFI=", + "errorPath": "frame.magic" + }, + { + "name": "unsupported-version", + "base64": "QVhMUgIBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAAAIAAEC/0FYTFI=", + "errorPath": "frame.transportVersion" + }, + { + "name": "truncated-header", + "base64": "QVhMUgEBERERERERQRGBERERERERESIiIiIiIkIi", + "errorPath": "frame" + }, + { + "name": "payload-length-mismatch", + "base64": "QVhMUgEBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAAAJAAEC/0FYTFI=", + "errorPath": "frame.opaquePayload" + }, + { + "name": "unknown-kind", + "base64": "QVhMUgEJERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAAAIAAEC/0FYTFI=", + "errorPath": "frame" + } + ] +} diff --git a/packages/protocol/test/remote-transport.test.ts b/packages/protocol/test/remote-transport.test.ts new file mode 100644 index 00000000..63b60f8e --- /dev/null +++ b/packages/protocol/test/remote-transport.test.ts @@ -0,0 +1,167 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + decodeBase64, + DEFAULT_RELAY_LIMITS, + encodeBase64, + encodeInternalConsumeRelayTicketRequest, + encodeRelayBinaryFrame, + MAX_RELAY_FRAME_BYTES, + MAX_RELAY_OPAQUE_PAYLOAD_BYTES, + parseInternalConsumeRelayTicketRequest, + parseDeviceId, + parseInternalConsumeRelayTicketResult, + parseIssueRelayTicketRequest, + parseRelayBinaryFrame, + parseRelayRevocationNotification, + ProtocolValidationError, + REMOTE_TRANSPORT_VERSION, + type RelayBinaryFrame, +} from "../src/index.ts"; +import { DeterministicFakeRemoteCryptoAdapter } from "./support/fake-remote-crypto.ts"; + +interface BinaryFixture { + readonly accepted: readonly { + readonly name: string; + readonly base64: string; + readonly frame: Readonly>; + }[]; + readonly rejected: readonly { + readonly name: string; + readonly base64: string; + readonly errorPath: string; + }[]; +} + +const binaryFixtures = JSON.parse( + readFileSync(new URL("./fixtures/remote-transport-v1.json", import.meta.url), "utf8"), +) as BinaryFixture; +const internalFixtures = JSON.parse( + readFileSync(new URL("./fixtures/internal-relay-api-v1.json", import.meta.url), "utf8"), +) as { + readonly consumeTicket: { readonly request: unknown; readonly result: unknown }; + readonly revocation: { readonly request: unknown; readonly result: unknown }; +}; + +function fixtureShape(frame: RelayBinaryFrame): Readonly> { + if ("destinationRouteId" in frame) { + return { + kind: "send", + attemptId: frame.attemptId, + routeId: frame.destinationRouteId, + opaquePayloadBase64: encodeBase64(frame.opaquePayload), + }; + } + if ("sourceRouteId" in frame) { + return { + kind: "delivery", + attemptId: frame.attemptId, + routeId: frame.sourceRouteId, + opaquePayloadBase64: encodeBase64(frame.opaquePayload), + }; + } + if ("status" in frame) + return { kind: "receipt", attemptId: frame.attemptId, status: frame.status }; + return { kind: "failure", attemptId: frame.attemptId, code: frame.code }; +} + +test("accepts and reproduces every canonical relay frame", () => { + for (const fixture of binaryFixtures.accepted) { + const bytes = decodeBase64(fixture.base64, `${fixture.name}.base64`, MAX_RELAY_FRAME_BYTES); + const parsed = parseRelayBinaryFrame(bytes); + assert.deepEqual(fixtureShape(parsed), fixture.frame, fixture.name); + assert.deepEqual(encodeRelayBinaryFrame(parsed), bytes, fixture.name); + } +}); + +test("rejects every malformed canonical relay frame", () => { + for (const fixture of binaryFixtures.rejected) { + const bytes = decodeBase64(fixture.base64, `${fixture.name}.base64`, MAX_RELAY_FRAME_BYTES); + assert.throws( + () => parseRelayBinaryFrame(bytes), + (error) => error instanceof ProtocolValidationError && error.path === fixture.errorPath, + fixture.name, + ); + } +}); + +test("enforces the complete frame bound before encoding", () => { + const attemptId = "11111111-1111-4111-8111-111111111111" as const; + const destinationRouteId = "22222222-2222-4222-8222-222222222222" as const; + const frame = { + transportVersion: REMOTE_TRANSPORT_VERSION, + attemptId, + destinationRouteId, + opaquePayload: new Uint8Array(MAX_RELAY_OPAQUE_PAYLOAD_BYTES + 1), + } as RelayBinaryFrame; + assert.throws( + () => encodeRelayBinaryFrame(frame), + (error) => error instanceof ProtocolValidationError && error.path === "frame.opaquePayload", + ); +}); + +test("keeps the deterministic fake E2EE adapter in test support", async () => { + const daemonId = parseDeviceId("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + const deviceId = parseDeviceId("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); + const daemon = new DeterministicFakeRemoteCryptoAdapter(daemonId, deviceId); + const device = new DeterministicFakeRemoteCryptoAdapter(deviceId, daemonId); + const plaintext = Uint8Array.of(0, 1, 2, 255); + + const opaque = await device.seal(daemonId, plaintext); + assert.match(new TextDecoder().decode(opaque), /TEST_ONLY_NOT_ENCRYPTED/); + assert.deepEqual(await daemon.open(opaque), { + authenticatedDeviceId: deviceId, + plaintext, + }); + + const modified = JSON.parse(new TextDecoder().decode(opaque)) as Record; + modified.sourceDeviceId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + await assert.rejects(daemon.open(new TextEncoder().encode(JSON.stringify(modified)))); +}); + +test("validates ticket roles and the language-neutral internal contract", () => { + assert.deepEqual( + parseIssueRelayTicketRequest({ + installationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + deviceId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + role: "device", + }), + { + installationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + deviceId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + role: "device", + }, + ); + assert.throws( + () => + parseIssueRelayTicketRequest({ + installationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + role: "device", + }), + (error) => error instanceof ProtocolValidationError && error.path === "request.deviceId", + ); + + const request = parseInternalConsumeRelayTicketRequest(internalFixtures.consumeTicket.request); + assert.deepEqual([...request.possessionProof], [0, 1, 2, 3, 255]); + assert.deepEqual( + encodeInternalConsumeRelayTicketRequest(request), + internalFixtures.consumeTicket.request, + ); + assert.deepEqual(parseInternalConsumeRelayTicketResult(internalFixtures.consumeTicket.result), { + installationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + deviceId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + sourceRouteId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + role: "device", + leaseExpiresAt: 2_000_000_000_000, + limits: DEFAULT_RELAY_LIMITS, + }); + assert.deepEqual( + parseRelayRevocationNotification(internalFixtures.revocation.request), + internalFixtures.revocation.request, + ); +}); diff --git a/packages/protocol/test/support/fake-remote-crypto.ts b/packages/protocol/test/support/fake-remote-crypto.ts new file mode 100644 index 00000000..245313ff --- /dev/null +++ b/packages/protocol/test/support/fake-remote-crypto.ts @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import { decodeBase64, encodeBase64, parseDeviceId, type DeviceId } from "../../src/index.ts"; + +export interface AuthenticatedPlaintext { + readonly authenticatedDeviceId: DeviceId; + readonly plaintext: Uint8Array; +} + +export interface RemoteCryptoAdapter { + open(opaqueEnvelope: Uint8Array): Promise; + seal(destinationDeviceId: DeviceId, plaintext: Uint8Array): Promise; +} + +interface FakeEnvelope { + readonly warning: "TEST_ONLY_NOT_ENCRYPTED"; + readonly sourceDeviceId: string; + readonly destinationDeviceId: string; + readonly plaintextBase64: string; +} + +/** Deterministic test framing. It provides no confidentiality, integrity, or replay protection. */ +export class DeterministicFakeRemoteCryptoAdapter implements RemoteCryptoAdapter { + private readonly localDeviceId: DeviceId; + private readonly expectedRemoteDeviceId: DeviceId; + + constructor(localDeviceId: DeviceId, expectedRemoteDeviceId: DeviceId) { + this.localDeviceId = localDeviceId; + this.expectedRemoteDeviceId = expectedRemoteDeviceId; + } + + async open(opaqueEnvelope: Uint8Array): Promise { + let candidate: FakeEnvelope; + try { + candidate = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(opaqueEnvelope)); + } catch (cause) { + throw new Error("Fake E2EE envelope is invalid", { cause }); + } + if ( + candidate.warning !== "TEST_ONLY_NOT_ENCRYPTED" || + parseDeviceId(candidate.sourceDeviceId) !== this.expectedRemoteDeviceId || + parseDeviceId(candidate.destinationDeviceId) !== this.localDeviceId + ) { + throw new Error("Fake E2EE envelope identity does not match the test endpoints"); + } + return { + authenticatedDeviceId: this.expectedRemoteDeviceId, + plaintext: decodeBase64(candidate.plaintextBase64, "fakeEnvelope.plaintextBase64", 65_535), + }; + } + + async seal(destinationDeviceId: DeviceId, plaintext: Uint8Array): Promise { + if (destinationDeviceId !== this.expectedRemoteDeviceId) { + throw new Error("Fake E2EE destination does not match the configured test endpoint"); + } + return new TextEncoder().encode( + JSON.stringify({ + warning: "TEST_ONLY_NOT_ENCRYPTED", + sourceDeviceId: this.localDeviceId, + destinationDeviceId, + plaintextBase64: encodeBase64(plaintext), + } satisfies FakeEnvelope), + ); + } +} From 726cb8a07ac4b7d382eab65c83508b40f59fb414 Mon Sep 17 00:00:00 2001 From: Lokesh Date: Sat, 12 Sep 2026 17:29:41 +0400 Subject: [PATCH 02/16] feat(control-plane): add atomic relay ticket admission Signed-off-by: Lokesh --- package.json | 5 +- pnpm-lock.yaml | 6 + pnpm-workspace.yaml | 2 + services/control-plane/README.md | 10 + services/control-plane/package.json | 25 +++ services/control-plane/src/index.ts | 6 + services/control-plane/src/revocations.ts | 27 +++ services/control-plane/src/server.ts | 138 +++++++++++++ services/control-plane/src/tickets.ts | 197 ++++++++++++++++++ services/control-plane/test/tickets.test.ts | 209 ++++++++++++++++++++ services/control-plane/tsconfig.build.json | 12 ++ services/control-plane/tsconfig.json | 7 + tsconfig.base.json | 1 + tsconfig.json | 2 +- 14 files changed, 644 insertions(+), 3 deletions(-) create mode 100644 services/control-plane/README.md create mode 100644 services/control-plane/package.json create mode 100644 services/control-plane/src/index.ts create mode 100644 services/control-plane/src/revocations.ts create mode 100644 services/control-plane/src/server.ts create mode 100644 services/control-plane/src/tickets.ts create mode 100644 services/control-plane/test/tickets.test.ts create mode 100644 services/control-plane/tsconfig.build.json create mode 100644 services/control-plane/tsconfig.json diff --git a/package.json b/package.json index 838d22b4..73ef4290 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "node": "^22.19.0 || >=24.0.0" }, "scripts": { - "build": "tsc -b packages/*/tsconfig.build.json packages/extensions/*/tsconfig.build.json --force && pnpm --filter @axl/web build", + "build": "tsc -b packages/*/tsconfig.build.json packages/extensions/*/tsconfig.build.json services/*/tsconfig.build.json --force && pnpm --filter @axl/web build", "build:release": "node scripts/build-release-package.ts", "build:release-metadata": "node scripts/build-release-metadata.ts", "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm check:boundaries && pnpm check:generated", @@ -20,7 +20,8 @@ "lint": "biome lint --error-on-warnings .", "release": "node scripts/release.ts", "release:preview": "node scripts/release.ts --preview", - "test": "pnpm build && node --test --test-concurrency=1 --test-timeout=30000 packages/*/test/*.test.ts packages/extensions/*/test/*.test.ts scripts/*.test.ts", + "relay:check": "cd services/relay && mix format --check-formatted && mix compile --warnings-as-errors && mix test && mix credo --strict && mix dialyzer && mix deps.audit", + "test": "pnpm build && node --test --test-concurrency=1 --test-timeout=30000 packages/*/test/*.test.ts packages/extensions/*/test/*.test.ts services/*/test/*.test.ts scripts/*.test.ts", "typecheck": "tsc --noEmit && pnpm --filter @axl/ui typecheck && pnpm --filter @axl/web typecheck" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5375298..280e6c77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,6 +277,12 @@ importers: specifier: 8.2.2 version: 8.2.2(@types/node@22.19.19)(esbuild@0.28.2)(yaml@2.8.3) + services/control-plane: + dependencies: + '@axl/protocol': + specifier: workspace:* + version: link:../../packages/protocol + packages: '@aws-sdk/core@3.977.9': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9ac38dc1..ef9913f3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,9 +1,11 @@ # SPDX-FileCopyrightText: 2026 Hari Srinivasan +# SPDX-FileCopyrightText: 2026 Lokesh # SPDX-License-Identifier: Apache-2.0 packages: - packages/* - packages/extensions/* + - services/* - apps/* allowBuilds: esbuild: false diff --git a/services/control-plane/README.md b/services/control-plane/README.md new file mode 100644 index 00000000..0d34b5e5 --- /dev/null +++ b/services/control-plane/README.md @@ -0,0 +1,10 @@ + + + +# Axl control plane + +This separately deployable TypeScript service owns hosted control-plane mutations. The first slice implements authorized relay-ticket issuance, atomic one-use consumption, and the authenticated internal HTTP boundary used by the relay. + +The service uses injected principal authentication, relay authentication, authorization, proof verification, clocks, and stores. Tests use deterministic in-memory implementations. No production identity provider, datastore, service-authentication scheme, or cryptographic proof is selected. + +The service never logs or places tickets or internal credentials in URLs. Production assembly remains blocked until those deployment decisions receive owner approval. diff --git a/services/control-plane/package.json b/services/control-plane/package.json new file mode 100644 index 00000000..8c3b1499 --- /dev/null +++ b/services/control-plane/package.json @@ -0,0 +1,25 @@ +{ + "name": "@axl/control-plane", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Hosted Axl control-plane service", + "license": "Apache-2.0", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -b tsconfig.build.json --force", + "test": "pnpm --filter @axl/protocol build && node --test test/*.test.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@axl/protocol": "workspace:*" + } +} diff --git a/services/control-plane/src/index.ts b/services/control-plane/src/index.ts new file mode 100644 index 00000000..a807c194 --- /dev/null +++ b/services/control-plane/src/index.ts @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +export * from "./revocations.ts"; +export * from "./server.ts"; +export * from "./tickets.ts"; diff --git a/services/control-plane/src/revocations.ts b/services/control-plane/src/revocations.ts new file mode 100644 index 00000000..2f3eb8e5 --- /dev/null +++ b/services/control-plane/src/revocations.ts @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import { + parseRelayRevocationNotification, + parseRelayRevocationResult, + type RelayRevocationNotification, + type RelayRevocationResult, +} from "@axl/protocol"; + +export interface AuthenticatedRelayInternalTransport { + post(path: string, body: unknown): Promise; +} + +export class RelayRevocationNotifier { + private readonly transport: AuthenticatedRelayInternalTransport; + + constructor(transport: AuthenticatedRelayInternalTransport) { + this.transport = transport; + } + + async notify(value: RelayRevocationNotification): Promise { + const notification = parseRelayRevocationNotification(value); + const response = await this.transport.post("/internal/v1/revocations", notification); + return parseRelayRevocationResult(response); + } +} diff --git a/services/control-plane/src/server.ts b/services/control-plane/src/server.ts new file mode 100644 index 00000000..d0ff7e09 --- /dev/null +++ b/services/control-plane/src/server.ts @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import type { IncomingMessage, RequestListener, ServerResponse } from "node:http"; + +import { + encodeInternalConsumeRelayTicketResult, + parseInternalConsumeRelayTicketRequest, + ProtocolValidationError, +} from "@axl/protocol"; + +import { RelayTicketError, type AccountPrincipal, type RelayTicketService } from "./tickets.ts"; + +const MAX_REQUEST_BYTES = 4_096; + +export interface PublicPrincipalAuthenticator { + authenticate(request: IncomingMessage): Promise; +} + +export interface InternalRelayAuthenticator { + authenticate(request: IncomingMessage, exactBody: Uint8Array): Promise; +} + +export interface ControlPlaneHandlerOptions { + readonly tickets: RelayTicketService; + readonly publicAuthentication: PublicPrincipalAuthenticator; + readonly internalAuthentication: InternalRelayAuthenticator; +} + +class HttpRequestError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "HttpRequestError"; + this.status = status; + } +} + +async function readBody(request: IncomingMessage): Promise { + const chunks: Uint8Array[] = []; + let size = 0; + for await (const chunk of request) { + const bytes = + typeof chunk === "string" ? new TextEncoder().encode(chunk) : new Uint8Array(chunk); + size += bytes.byteLength; + if (size > MAX_REQUEST_BYTES) throw new HttpRequestError(413, "Request body is too large"); + chunks.push(bytes); + } + const body = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} + +function parseJson(body: Uint8Array): unknown { + if (body.byteLength === 0) throw new HttpRequestError(400, "Request body is required"); + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)); + } catch { + throw new HttpRequestError(400, "Request body must be valid UTF-8 JSON"); + } +} + +function respond(response: ServerResponse, status: number, body: unknown): void { + const bytes = new TextEncoder().encode(JSON.stringify(body)); + response.writeHead(status, { + "cache-control": "no-store", + "content-length": bytes.byteLength, + "content-type": "application/json; charset=utf-8", + "x-content-type-options": "nosniff", + }); + response.end(bytes); +} + +function respondError(response: ServerResponse, error: unknown): void { + if (error instanceof RelayTicketError) { + respond(response, error.httpStatus, { error: { code: error.code, message: error.message } }); + return; + } + if (error instanceof ProtocolValidationError) { + respond(response, 400, { + error: { code: "bad_request", message: "Request validation failed", path: error.path }, + }); + return; + } + if (error instanceof HttpRequestError) { + respond(response, error.status, { error: { code: "bad_request", message: error.message } }); + return; + } + respond(response, 503, { + error: { code: "service_unavailable", message: "Control plane is unavailable" }, + }); +} + +function requestPath(request: IncomingMessage): string | undefined { + if (request.url === undefined) return undefined; + const url = new URL(request.url, "http://control-plane.invalid"); + return url.search === "" ? url.pathname : undefined; +} + +export function createControlPlaneHandler(options: ControlPlaneHandlerOptions): RequestListener { + return (request, response) => { + void (async () => { + if (request.method !== "POST") { + respond(response, 405, { error: { code: "method_not_allowed" } }); + return; + } + const path = requestPath(request); + if (path === "/v1/relay/tickets") { + const principal = await options.publicAuthentication.authenticate(request); + if (principal === undefined) { + respond(response, 401, { error: { code: "unauthorized" } }); + return; + } + const result = await options.tickets.issue(principal, parseJson(await readBody(request))); + respond(response, 201, result); + return; + } + if (path === "/internal/v1/relay/tickets/consume") { + const body = await readBody(request); + if (!(await options.internalAuthentication.authenticate(request, body))) { + respond(response, 401, { error: { code: "unauthorized" } }); + return; + } + const result = await options.tickets.consume( + parseInternalConsumeRelayTicketRequest(parseJson(body)), + ); + respond(response, 200, encodeInternalConsumeRelayTicketResult(result)); + return; + } + respond(response, 404, { error: { code: "not_found" } }); + })().catch((error: unknown) => respondError(response, error)); + }; +} diff --git a/services/control-plane/src/tickets.ts b/services/control-plane/src/tickets.ts new file mode 100644 index 00000000..bb8fc0cf --- /dev/null +++ b/services/control-plane/src/tickets.ts @@ -0,0 +1,197 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import { createHash, randomBytes, randomUUID } from "node:crypto"; + +import { + DEFAULT_RELAY_LIMITS, + parseIssueRelayTicketRequest, + parseIssueRelayTicketResult, + parseRelayLimits, + parseRouteId, + RELAY_TICKET_LIFETIME_MS, + type ConsumeRelayTicketRequest, + type ConsumeRelayTicketResult, + type IssueRelayTicketRequest, + type IssueRelayTicketResult, + type RelayLimits, +} from "@axl/protocol"; + +export interface AccountPrincipal { + readonly accountId: string; +} + +export interface Clock { + now(): number; +} + +export interface RelayTicketAuthorizer { + authorize(principal: AccountPrincipal, request: IssueRelayTicketRequest): Promise; +} + +export interface RelayTicketProofVerifier { + verify(ticket: Readonly, request: ConsumeRelayTicketRequest): Promise; +} + +export interface RelayTicketRecord extends IssueRelayTicketRequest { + readonly ticketDigest: string; + readonly sourceRouteId: ConsumeRelayTicketResult["sourceRouteId"]; + readonly issuedAt: number; + readonly expiresAt: number; + readonly limits: RelayLimits; + consumedAt?: number; + consumedByRelayInstanceId?: string; +} + +export interface RelayTicketStore { + insert(record: RelayTicketRecord): Promise; + find(ticketDigest: string): Promise | undefined>; + /** Atomically returns and marks one unexpired ticket as consumed. */ + consume( + ticketDigest: string, + relayInstanceId: string, + now: number, + ): Promise>; +} + +export type RelayTicketErrorCode = + | "unauthorized" + | "forbidden_route" + | "ticket_expired" + | "ticket_consumed" + | "service_unavailable"; + +export class RelayTicketError extends Error { + readonly code: RelayTicketErrorCode; + readonly httpStatus: number; + + constructor(code: RelayTicketErrorCode, message: string, httpStatus: number) { + super(message); + this.name = "RelayTicketError"; + this.code = code; + this.httpStatus = httpStatus; + } +} + +export class InMemoryRelayTicketStore implements RelayTicketStore { + private readonly records = new Map(); + + async insert(record: RelayTicketRecord): Promise { + if (this.records.has(record.ticketDigest)) throw new Error("Relay ticket digest collision"); + this.records.set(record.ticketDigest, record); + } + + async find(ticketDigest: string): Promise | undefined> { + return this.records.get(ticketDigest); + } + + async consume( + ticketDigest: string, + relayInstanceId: string, + now: number, + ): Promise> { + const record = this.records.get(ticketDigest); + if (record === undefined) { + throw new RelayTicketError("unauthorized", "Relay ticket is invalid", 401); + } + if (record.expiresAt <= now) { + throw new RelayTicketError("ticket_expired", "Relay ticket has expired", 401); + } + if (record.consumedAt !== undefined) { + throw new RelayTicketError("ticket_consumed", "Relay ticket has already been consumed", 409); + } + record.consumedAt = now; + record.consumedByRelayInstanceId = relayInstanceId; + return record; + } +} + +export interface RelayTicketServiceOptions { + readonly store: RelayTicketStore; + readonly authorizer: RelayTicketAuthorizer; + readonly proofVerifier: RelayTicketProofVerifier; + readonly relayUrl: string; + readonly clock?: Clock; + readonly limits?: RelayLimits; + readonly ticketLifetimeMs?: number; + readonly randomToken?: () => string; + readonly randomId?: () => string; +} + +function digestTicket(ticket: string): string { + return createHash("sha256").update(ticket, "utf8").digest("hex"); +} + +export class RelayTicketService { + private readonly options: RelayTicketServiceOptions; + private readonly clock: Clock; + private readonly limits: RelayLimits; + private readonly ticketLifetimeMs: number; + private readonly randomToken: () => string; + private readonly randomId: () => string; + + constructor(options: RelayTicketServiceOptions) { + this.options = options; + this.clock = options.clock ?? { now: () => Date.now() }; + this.limits = parseRelayLimits(options.limits ?? DEFAULT_RELAY_LIMITS); + this.ticketLifetimeMs = options.ticketLifetimeMs ?? RELAY_TICKET_LIFETIME_MS; + this.randomToken = options.randomToken ?? (() => randomBytes(32).toString("base64url")); + this.randomId = options.randomId ?? randomUUID; + if ( + !Number.isSafeInteger(this.ticketLifetimeMs) || + this.ticketLifetimeMs <= 0 || + this.ticketLifetimeMs > RELAY_TICKET_LIFETIME_MS + ) { + throw new TypeError(`Ticket lifetime must be from 1 through ${RELAY_TICKET_LIFETIME_MS} ms`); + } + } + + async issue(principal: AccountPrincipal, value: unknown): Promise { + const request = parseIssueRelayTicketRequest(value); + if (!(await this.options.authorizer.authorize(principal, request))) { + throw new RelayTicketError("forbidden_route", "Principal cannot access this route", 403); + } + const now = this.clock.now(); + const ticket = this.randomToken(); + const record: RelayTicketRecord = { + ...request, + ticketDigest: digestTicket(ticket), + sourceRouteId: parseRouteId(this.randomId(), "sourceRouteId"), + issuedAt: now, + expiresAt: now + this.ticketLifetimeMs, + limits: this.limits, + }; + await this.options.store.insert(record); + return parseIssueRelayTicketResult({ + ticket, + relayUrl: this.options.relayUrl, + expiresAt: record.expiresAt, + proofSchemeVersion: 1, + limits: record.limits, + }); + } + + async consume(request: ConsumeRelayTicketRequest): Promise { + const ticketDigest = digestTicket(request.ticket); + const candidate = await this.options.store.find(ticketDigest); + if (candidate === undefined) { + throw new RelayTicketError("unauthorized", "Relay ticket is invalid", 401); + } + if (!(await this.options.proofVerifier.verify(candidate, request))) { + throw new RelayTicketError("unauthorized", "Possession proof is invalid", 401); + } + const consumed = await this.options.store.consume( + ticketDigest, + request.relayInstanceId, + this.clock.now(), + ); + return { + installationId: consumed.installationId, + ...(consumed.deviceId === undefined ? {} : { deviceId: consumed.deviceId }), + sourceRouteId: consumed.sourceRouteId, + role: consumed.role, + leaseExpiresAt: consumed.expiresAt, + limits: consumed.limits, + }; + } +} diff --git a/services/control-plane/test/tickets.test.ts b/services/control-plane/test/tickets.test.ts new file mode 100644 index 00000000..33d73eba --- /dev/null +++ b/services/control-plane/test/tickets.test.ts @@ -0,0 +1,209 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { once } from "node:events"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + DEFAULT_RELAY_LIMITS, + encodeInternalConsumeRelayTicketRequest, + INTERNAL_RELAY_API_VERSION, + parseInstallationId, + parseRelayRevocationNotification, +} from "@axl/protocol"; + +import { + createControlPlaneHandler, + InMemoryRelayTicketStore, + RelayRevocationNotifier, + RelayTicketError, + RelayTicketService, +} from "../src/index.ts"; + +const installationId = parseInstallationId("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); +const deviceId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" as const; +const fixture = JSON.parse( + readFileSync( + new URL("../../../packages/protocol/test/fixtures/internal-relay-api-v1.json", import.meta.url), + "utf8", + ), +) as { + readonly revocation: { readonly request: unknown; readonly result: unknown }; +}; + +function createTicketService( + clock: { now(): number } = { now: () => 1_900_000_000_000 }, +): RelayTicketService { + let routeCounter = 0; + return new RelayTicketService({ + store: new InMemoryRelayTicketStore(), + authorizer: { + async authorize(principal, request) { + return ( + principal.accountId === "account-fixture" && request.installationId === installationId + ); + }, + }, + proofVerifier: { + async verify(_ticket, request) { + return Buffer.from(request.possessionProof).equals(Buffer.from([0, 1, 2, 3, 255])); + }, + }, + relayUrl: "wss://relay.invalid/v1/connect", + clock, + randomToken: () => "fixture-ticket-never-valid-outside-tests", + randomId: () => { + routeCounter += 1; + return `cccccccc-cccc-4ccc-8ccc-${routeCounter.toString().padStart(12, "0")}`; + }, + }); +} + +test("atomically consumes a relay ticket once under concurrent calls", async () => { + const service = createTicketService(); + const issued = await service.issue( + { accountId: "account-fixture" }, + { installationId, deviceId, role: "device" }, + ); + const request = { + ticket: issued.ticket, + relayInstanceId: "relay-fixture-1", + connectionNonce: "fixture-nonce", + possessionProof: Uint8Array.of(0, 1, 2, 3, 255), + }; + + const results = await Promise.allSettled([service.consume(request), service.consume(request)]); + assert.equal(results.filter((result) => result.status === "fulfilled").length, 1); + const rejection = results.find((result) => result.status === "rejected"); + assert.ok(rejection?.status === "rejected"); + assert.ok(rejection.reason instanceof RelayTicketError); + assert.equal(rejection.reason.code, "ticket_consumed"); +}); + +test("rejects unauthorized issuance, invalid proof, and expired tickets", async () => { + const service = createTicketService(); + await assert.rejects( + service.issue({ accountId: "another-account" }, { installationId, deviceId, role: "device" }), + (error) => error instanceof RelayTicketError && error.code === "forbidden_route", + ); + const issued = await service.issue( + { accountId: "account-fixture" }, + { installationId, deviceId, role: "device" }, + ); + await assert.rejects( + service.consume({ + ticket: issued.ticket, + relayInstanceId: "relay-fixture-1", + connectionNonce: "fixture-nonce", + possessionProof: Uint8Array.of(9), + }), + (error) => error instanceof RelayTicketError && error.code === "unauthorized", + ); + + let now = 1_900_000_000_000; + const expiringService = createTicketService({ now: () => now }); + const expiring = await expiringService.issue( + { accountId: "account-fixture" }, + { installationId, deviceId, role: "device" }, + ); + now += 60_000; + await assert.rejects( + expiringService.consume({ + ticket: expiring.ticket, + relayInstanceId: "relay-fixture-1", + connectionNonce: "fixture-nonce", + possessionProof: Uint8Array.of(0, 1, 2, 3, 255), + }), + (error) => error instanceof RelayTicketError && error.code === "ticket_expired", + ); +}); + +test("serves authenticated public issuance and internal consumption without URL credentials", async (context) => { + const service = createTicketService(); + const handler = createControlPlaneHandler({ + tickets: service, + publicAuthentication: { + async authenticate(request) { + return request.headers.authorization === "Bearer public-fixture" + ? { accountId: "account-fixture" } + : undefined; + }, + }, + internalAuthentication: { + async authenticate(request) { + return request.headers.authorization === "Bearer internal-fixture"; + }, + }, + }); + const server = createServer(handler); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + context.after(() => server.close()); + const address = server.address(); + assert.ok(address !== null && typeof address !== "string"); + const origin = `http://127.0.0.1:${address.port}`; + + const issueResponse = await fetch(`${origin}/v1/relay/tickets`, { + method: "POST", + headers: { authorization: "Bearer public-fixture", "content-type": "application/json" }, + body: JSON.stringify({ installationId, deviceId, role: "device" }), + }); + assert.equal(issueResponse.status, 201); + const issued = (await issueResponse.json()) as { readonly ticket: string }; + + const unauthorized = await fetch(`${origin}/internal/v1/relay/tickets/consume`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify( + encodeInternalConsumeRelayTicketRequest({ + ticket: issued.ticket, + relayInstanceId: "relay-fixture-1", + connectionNonce: "fixture-nonce", + possessionProof: Uint8Array.of(0, 1, 2, 3, 255), + }), + ), + }); + assert.equal(unauthorized.status, 401); + + const consumeResponse = await fetch(`${origin}/internal/v1/relay/tickets/consume`, { + method: "POST", + headers: { authorization: "Bearer internal-fixture", "content-type": "application/json" }, + body: JSON.stringify( + encodeInternalConsumeRelayTicketRequest({ + ticket: issued.ticket, + relayInstanceId: "relay-fixture-1", + connectionNonce: "fixture-nonce", + possessionProof: Uint8Array.of(0, 1, 2, 3, 255), + }), + ), + }); + assert.equal(consumeResponse.status, 200); + assert.deepEqual(await consumeResponse.json(), { + version: INTERNAL_RELAY_API_VERSION, + installationId, + deviceId, + sourceRouteId: "cccccccc-cccc-4ccc-8ccc-000000000001", + role: "device", + leaseExpiresAt: 1_900_000_060_000, + limits: DEFAULT_RELAY_LIMITS, + }); +}); + +test("validates the authenticated relay revocation boundary", async () => { + let observedPath: string | undefined; + let observedBody: unknown; + const notifier = new RelayRevocationNotifier({ + async post(path, body) { + observedPath = path; + observedBody = body; + return fixture.revocation.result; + }, + }); + const notification = parseRelayRevocationNotification(fixture.revocation.request); + assert.deepEqual(await notifier.notify(notification), fixture.revocation.result); + assert.equal(observedPath, "/internal/v1/revocations"); + assert.deepEqual(observedBody, notification); +}); diff --git a/services/control-plane/tsconfig.build.json b/services/control-plane/tsconfig.build.json new file mode 100644 index 00000000..083eb148 --- /dev/null +++ b/services/control-plane/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "test"], + "references": [{ "path": "../../packages/protocol/tsconfig.build.json" }] +} diff --git a/services/control-plane/tsconfig.json b/services/control-plane/tsconfig.json new file mode 100644 index 00000000..7baee43a --- /dev/null +++ b/services/control-plane/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index a1f39a3e..8b72c4bc 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -9,6 +9,7 @@ "paths": { "@axl/ai": ["./packages/ai/src/index.ts"], "@axl/ai/models": ["./packages/ai/src/models.ts"], + "@axl/control-plane": ["./services/control-plane/src/index.ts"], "@axl/daemon": ["./packages/daemon/src/index.ts"], "@axl/daemon/client": ["./packages/daemon/src/client.ts"], "@axl/extension-api": ["./packages/extensions/api/src/index.ts"], diff --git a/tsconfig.json b/tsconfig.json index e21b7a71..db27a9cb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,6 @@ "compilerOptions": { "noEmit": true }, - "include": ["packages/**/*.ts", "scripts/**/*.ts"], + "include": ["packages/**/*.ts", "services/**/*.ts", "scripts/**/*.ts"], "exclude": ["packages/ui", "packages/web"] } From f76c763876879d0619486292bf7fd60d1479912e Mon Sep 17 00:00:00 2001 From: Lokesh Date: Sat, 12 Sep 2026 17:29:51 +0400 Subject: [PATCH 03/16] feat(relay): add bounded opaque WebSocket routing Signed-off-by: Lokesh --- .github/workflows/ci.yml | 44 ++++ .gitignore | 3 + scripts/check-boundaries.test.ts | 24 ++ scripts/check-boundaries.ts | 56 +++- scripts/check-generated.test.ts | 14 +- scripts/check-generated.ts | 4 +- services/relay/.formatter.exs | 6 + services/relay/.tool-versions | 2 + services/relay/README.md | 18 ++ services/relay/lib/axl_relay/admission.ex | 52 ++++ services/relay/lib/axl_relay/application.ex | 19 ++ services/relay/lib/axl_relay/connection.ex | 176 +++++++++++++ services/relay/lib/axl_relay/frame.ex | 168 ++++++++++++ .../axl_relay/http_control_plane_client.ex | 167 ++++++++++++ services/relay/lib/axl_relay/listener.ex | 32 +++ .../relay/lib/axl_relay/revocation_handler.ex | 48 ++++ .../relay/lib/axl_relay/route_registry.ex | 205 ++++++++++++++ services/relay/lib/axl_relay/router.ex | 75 ++++++ services/relay/mix.exs | 35 +++ services/relay/mix.lock | 20 ++ services/relay/test/frame_test.exs | 59 +++++ .../relay/test/internal_contract_test.exs | 52 ++++ services/relay/test/route_registry_test.exs | 115 ++++++++ services/relay/test/test_helper.exs | 4 + services/relay/test/websocket_relay_test.exs | 249 ++++++++++++++++++ 25 files changed, 1641 insertions(+), 6 deletions(-) create mode 100644 services/relay/.formatter.exs create mode 100644 services/relay/.tool-versions create mode 100644 services/relay/README.md create mode 100644 services/relay/lib/axl_relay/admission.ex create mode 100644 services/relay/lib/axl_relay/application.ex create mode 100644 services/relay/lib/axl_relay/connection.ex create mode 100644 services/relay/lib/axl_relay/frame.ex create mode 100644 services/relay/lib/axl_relay/http_control_plane_client.ex create mode 100644 services/relay/lib/axl_relay/listener.ex create mode 100644 services/relay/lib/axl_relay/revocation_handler.ex create mode 100644 services/relay/lib/axl_relay/route_registry.ex create mode 100644 services/relay/lib/axl_relay/router.ex create mode 100644 services/relay/mix.exs create mode 100644 services/relay/mix.lock create mode 100644 services/relay/test/frame_test.exs create mode 100644 services/relay/test/internal_contract_test.exs create mode 100644 services/relay/test/route_registry_test.exs create mode 100644 services/relay/test/test_helper.exs create mode 100644 services/relay/test/websocket_relay_test.exs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b0efb90..6da928c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,5 @@ # SPDX-FileCopyrightText: 2026 Hari Srinivasan +# SPDX-FileCopyrightText: 2026 Lokesh # SPDX-License-Identifier: Apache-2.0 name: CI @@ -27,6 +28,7 @@ jobs: outputs: code: ${{ steps.filter.outputs.code }} workflows: ${{ steps.filter.outputs.workflows }} + relay: ${{ steps.filter.outputs.relay }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4 @@ -35,6 +37,7 @@ jobs: filters: | code: - 'packages/**' + - 'services/**' - 'distribution/**' - 'scripts/**' - 'package.json' @@ -46,6 +49,12 @@ jobs: - '.github/workflows/ci.yml' workflows: - '.github/workflows/**' + relay: + - 'services/relay/**' + - 'packages/protocol/src/remote-transport.ts' + - 'packages/protocol/test/fixtures/remote-transport-v1.json' + - 'packages/protocol/test/fixtures/internal-relay-api-v1.json' + - '.github/workflows/ci.yml' quality: name: Build and test @@ -74,6 +83,41 @@ jobs: if: needs.changes.outputs.code == 'true' run: pnpm check + relay: + name: Relay build and test + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: changes + steps: + - name: No relay changes + if: needs.changes.outputs.relay != 'true' + run: echo "No relay changes detected; required check reports success." + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + if: needs.changes.outputs.relay == 'true' + - name: Set up Erlang and Elixir + if: needs.changes.outputs.relay == 'true' + uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 + with: + otp-version: 27.3.4.17 + elixir-version: 1.18.5 + - name: Restore Mix caches + if: needs.changes.outputs.relay == 'true' + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + services/relay/deps + services/relay/_build + ~/.mix + key: relay-${{ runner.os }}-${{ hashFiles('services/relay/mix.lock') }} + - name: Fetch relay dependencies + if: needs.changes.outputs.relay == 'true' + working-directory: services/relay + run: mix deps.get --check-locked + - name: Format, compile, test, analyze, and audit relay + if: needs.changes.outputs.relay == 'true' + working-directory: services/relay + run: mix format --check-formatted && mix compile --warnings-as-errors && mix test && mix credo --strict && mix dialyzer && mix deps.audit + licenses: name: REUSE licenses runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 5da15464..dce75226 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,13 @@ # SPDX-FileCopyrightText: 2026 Hari Srinivasan +# SPDX-FileCopyrightText: 2026 Lokesh # SPDX-License-Identifier: Apache-2.0 node_modules/ dist/ .release/ coverage/ +services/relay/_build/ +services/relay/deps/ docs/screenshots/ *.tsbuildinfo .env diff --git a/scripts/check-boundaries.test.ts b/scripts/check-boundaries.test.ts index 29d4e159..74aa24fa 100644 --- a/scripts/check-boundaries.test.ts +++ b/scripts/check-boundaries.test.ts @@ -68,6 +68,30 @@ test("enforces protocol, kernel, runtime, TUI, and extension dependency boundari ]); }); +test("enforces control-plane and relay service boundaries", () => { + const root = mkdtempSync(join(tmpdir(), "axl-service-boundaries-")); + const controlPlane = join(root, "services/control-plane"); + mkdirSync(join(controlPlane, "src"), { recursive: true }); + writeFileSync( + join(controlPlane, "package.json"), + JSON.stringify({ name: "@axl/control-plane", dependencies: { fastify: "1.0.0" } }), + ); + writeFileSync(join(controlPlane, "src/index.ts"), 'import "@axl/daemon";\n'); + const relay = join(root, "services/relay"); + mkdirSync(relay, { recursive: true }); + writeFileSync( + join(relay, "mix.exs"), + 'defp deps, do: [{:bandit, "1.12.5"}, {:forbidden, path: "../../packages/kernel"}]\n', + ); + + assert.deepEqual(checkWorkspace(root), [ + "services/control-plane may depend only on @axl/protocol, found fastify", + "services/control-plane/src/index.ts imports @axl/daemon; control plane may import only Node.js and @axl/protocol", + "services/relay may not depend on unapproved package forbidden", + "services/relay must not use path dependencies into repository packages", + ]); +}); + test("checks real imports without interpreting embedded clipboard scripts as dependencies", () => { const root = mkdtempSync(join(tmpdir(), "axl-boundary-syntax-")); writePackage( diff --git a/scripts/check-boundaries.ts b/scripts/check-boundaries.ts index ff452dcb..c82f9a7d 100644 --- a/scripts/check-boundaries.ts +++ b/scripts/check-boundaries.ts @@ -27,7 +27,8 @@ type PackageManifest = { function walk(directory: string, visit: (path: string) => void): void { if (!existsSync(directory)) return; for (const entry of readdirSync(directory, { withFileTypes: true })) { - if ([".git", "dist", "node_modules"].includes(entry.name)) continue; + if ([".git", "_build", "deps", "dist", "node_modules"].includes(entry.name)) continue; + if (entry.isSymbolicLink()) continue; const path = resolve(directory, entry.name); if (entry.isDirectory()) walk(path, visit); else visit(path); @@ -36,9 +37,11 @@ function walk(directory: string, visit: (path: string) => void): void { function packageDirectories(root: string): string[] { const directories: string[] = []; - walk(resolve(root, "packages"), (path) => { - if (path.endsWith(`${sep}package.json`)) directories.push(dirname(path)); - }); + for (const workspaceRoot of ["packages", "services"]) { + walk(resolve(root, workspaceRoot), (path) => { + if (path.endsWith(`${sep}package.json`)) directories.push(dirname(path)); + }); + } return directories; } @@ -93,6 +96,9 @@ export function checkWorkspace(root: string): string[] { const sdk = packages.find(({ directory }) => directory === resolve(root, "packages/sdk")); const ui = packages.find(({ directory }) => directory === resolve(root, "packages/ui")); const tui = packages.find(({ directory }) => directory === resolve(root, "packages/tui")); + const controlPlane = packages.find( + ({ directory }) => directory === resolve(root, "services/control-plane"), + ); const protocolName = protocol?.manifest.name ?? "@axl/protocol"; const kernelName = kernel?.manifest.name ?? "@axl/kernel"; const tuiName = tui?.manifest.name ?? "@axl/tui"; @@ -141,6 +147,16 @@ export function checkWorkspace(root: string): string[] { } } + if (controlPlane) { + for (const dependency of runtimeDependencies(controlPlane.manifest)) { + if (dependency !== protocolName) { + errors.push( + `${relative(root, controlPlane.directory)} may depend only on ${protocolName}, found ${dependency}`, + ); + } + } + } + if (runtime && runtimeDependencies(runtime.manifest).includes(tuiName)) { errors.push( `${relative(root, runtime.directory)} must not depend on presentation package ${tuiName}`, @@ -197,6 +213,16 @@ export function checkWorkspace(root: string): string[] { `${relative(root, path)} imports ${specifier}; UI source may import only shared client presentation packages`, ); } + if ( + directory === controlPlane?.directory && + !specifier.startsWith(".") && + !specifier.startsWith("node:") && + specifier !== protocolName + ) { + errors.push( + `${relative(root, path)} imports ${specifier}; control plane may import only Node.js and ${protocolName}`, + ); + } if ( directory === tui?.directory && !specifier.startsWith(".") && @@ -224,6 +250,28 @@ export function checkWorkspace(root: string): string[] { }); } + const relayMixPath = resolve(root, "services/relay/mix.exs"); + if (existsSync(relayMixPath)) { + const relayMix = readFileSync(relayMixPath, "utf8"); + const allowedRelayDependencies = new Set([ + "bandit", + "plug", + "websock_adapter", + "credo", + "dialyxir", + "mix_audit", + ]); + for (const match of relayMix.matchAll(/\{:([a-z][a-z0-9_]*),/g)) { + const dependency = match[1] as string; + if (!allowedRelayDependencies.has(dependency)) { + errors.push(`services/relay may not depend on unapproved package ${dependency}`); + } + } + if (/\bpath:\s*/.test(relayMix)) { + errors.push("services/relay must not use path dependencies into repository packages"); + } + } + walk(resolve(root, "apps"), (path) => { const extension = path.slice(path.lastIndexOf(".")); if (!sourceExtensions.has(extension)) return; diff --git a/scripts/check-generated.test.ts b/scripts/check-generated.test.ts index b3e2b01a..204da945 100644 --- a/scripts/check-generated.test.ts +++ b/scripts/check-generated.test.ts @@ -1,8 +1,9 @@ // SPDX-FileCopyrightText: 2026 Hari Srinivasan +// SPDX-FileCopyrightText: 2026 Lokesh // SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -24,3 +25,14 @@ test("requires generated files to name an existing passing generator", () => { [], ); }); + +test("ignores dependency builds and directory symlinks", () => { + const root = mkdtempSync(join(tmpdir(), "axl-generated-builds-")); + mkdirSync(join(root, "deps")); + mkdirSync(join(root, "_build")); + writeFileSync(join(root, "deps", "ignored.generated.ts"), ""); + writeFileSync(join(root, "_build", "ignored.generated.ts"), ""); + symlinkSync(join(root, "deps"), join(root, "linked-deps")); + + assert.deepEqual(checkGenerated(root), []); +}); diff --git a/scripts/check-generated.ts b/scripts/check-generated.ts index 391adf9c..b0663b96 100644 --- a/scripts/check-generated.ts +++ b/scripts/check-generated.ts @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2026 Hari Srinivasan +// SPDX-FileCopyrightText: 2026 Lokesh // SPDX-License-Identifier: Apache-2.0 import { execFileSync } from "node:child_process"; @@ -14,7 +15,8 @@ type GeneratorRunner = ( function walk(directory: string, visit: (path: string) => void): void { for (const entry of readdirSync(directory, { withFileTypes: true })) { - if ([".git", "dist", "node_modules"].includes(entry.name)) continue; + if ([".git", "_build", "deps", "dist", "node_modules"].includes(entry.name)) continue; + if (entry.isSymbolicLink()) continue; const path = resolve(directory, entry.name); if (entry.isDirectory()) walk(path, visit); else visit(path); diff --git a/services/relay/.formatter.exs b/services/relay/.formatter.exs new file mode 100644 index 00000000..5301699e --- /dev/null +++ b/services/relay/.formatter.exs @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/services/relay/.tool-versions b/services/relay/.tool-versions new file mode 100644 index 00000000..5767f96d --- /dev/null +++ b/services/relay/.tool-versions @@ -0,0 +1,2 @@ +erlang 27.3.4.17 +elixir 1.18.5-otp-27 diff --git a/services/relay/README.md b/services/relay/README.md new file mode 100644 index 00000000..f7a9aa54 --- /dev/null +++ b/services/relay/README.md @@ -0,0 +1,18 @@ + + + +# Axl relay + +This separately deployable Elixir/OTP service admits connections through the control plane and routes bounded opaque binary frames in memory. It has no E2EE, daemon RPC, canonical-event, account-database, or attachment-body dependency. + +The first slice provides: + +- one-use ticket admission through an injected control-plane client +- exact transport-v1 binary framing shared with TypeScript fixtures +- installation-scoped in-memory route registration +- bounded per-route pending bytes +- WebSocket compression disabled and a 65,535-byte frame ceiling +- heartbeat, idle, lease-expiry, revocation, and draining behavior +- fail-closed admission and internal-authentication interfaces + +Production control-plane origins, service authentication, TLS termination, and deployment configuration remain unselected. Tests use deterministic fake adapters. diff --git a/services/relay/lib/axl_relay/admission.ex b/services/relay/lib/axl_relay/admission.ex new file mode 100644 index 00000000..25857830 --- /dev/null +++ b/services/relay/lib/axl_relay/admission.ex @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.Admission do + @moduledoc "Parses the bounded, pre-routing WebSocket admission message." + + @max_message_bytes 4_096 + @required_keys MapSet.new([ + "version", + "ticket", + "connectionNonce", + "possessionProof" + ]) + + @spec parse(binary()) :: {:ok, map()} | {:error, :bad_frame} + def parse(message) when is_binary(message) and byte_size(message) <= @max_message_bytes do + with {:ok, decoded} <- decode_json(message), + true <- is_map(decoded), + true <- MapSet.new(Map.keys(decoded)) == @required_keys, + 1 <- decoded["version"], + ticket when is_binary(ticket) and byte_size(ticket) in 1..1024 <- decoded["ticket"], + nonce when is_binary(nonce) and byte_size(nonce) in 1..256 <- decoded["connectionNonce"], + proof when is_binary(proof) <- decoded["possessionProof"], + {:ok, proof_bytes} <- Base.decode64(proof), + true <- byte_size(proof_bytes) <= 1_024, + ^proof <- Base.encode64(proof_bytes) do + {:ok, + %{ + "ticket" => ticket, + "connectionNonce" => nonce, + "possessionProof" => proof + }} + else + _other -> {:error, :bad_frame} + end + end + + def parse(_message), do: {:error, :bad_frame} + + defp decode_json(message) do + {:ok, :json.decode(message)} + catch + _kind, _reason -> {:error, :bad_frame} + end +end + +defmodule AxlRelay.ControlPlaneClient do + @moduledoc "Injected fail-closed boundary for atomic ticket consumption." + + @callback consume_ticket(map(), String.t(), keyword()) :: + {:ok, map()} | {:error, atom()} +end diff --git a/services/relay/lib/axl_relay/application.ex b/services/relay/lib/axl_relay/application.ex new file mode 100644 index 00000000..61bf1891 --- /dev/null +++ b/services/relay/lib/axl_relay/application.ex @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.Application do + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = + case Application.get_env(:axl_relay, :listener_options) do + nil -> [AxlRelay.RouteRegistry] + options -> [AxlRelay.RouteRegistry, {AxlRelay.Listener, options}] + end + + Supervisor.start_link(children, strategy: :one_for_one, name: AxlRelay.Supervisor) + end +end diff --git a/services/relay/lib/axl_relay/connection.ex b/services/relay/lib/axl_relay/connection.ex new file mode 100644 index 00000000..6033e1f3 --- /dev/null +++ b/services/relay/lib/axl_relay/connection.ex @@ -0,0 +1,176 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.Connection do + @moduledoc "Ticket-admitted WebSock handler for opaque relay frames." + + @behaviour WebSock + + alias AxlRelay.{Admission, Frame, RouteRegistry} + + @admission_timeout_ms 5_000 + @rate_window_ms 10_000 + @max_frames_per_window 100 + + @impl true + def init(options) do + Process.send_after(self(), :admission_timeout, @admission_timeout_ms) + + {:ok, + %{ + phase: :awaiting_admission, + control_plane: Keyword.fetch!(options, :control_plane), + control_plane_options: Keyword.get(options, :control_plane_options, []), + relay_instance_id: Keyword.fetch!(options, :relay_instance_id), + registry: Keyword.get(options, :registry, RouteRegistry), + route_id: nil, + limits: nil, + rate_window_started: System.monotonic_time(:millisecond), + rate_frames: 0, + rate_bytes: 0 + }} + end + + @impl true + def handle_in({message, opcode: :binary}, %{phase: :awaiting_admission} = state) do + with {:ok, admission} <- Admission.parse(message), + {:ok, result} <- + state.control_plane.consume_ticket( + admission, + state.relay_instance_id, + state.control_plane_options + ), + true <- result.lease_expires_at > System.system_time(:millisecond), + :ok <- RouteRegistry.register(state.registry, self(), result) do + Process.send_after(self(), :heartbeat, result.limits.heartbeat_interval_ms) + + Process.send_after( + self(), + :lease_expired, + result.lease_expires_at - System.system_time(:millisecond) + ) + + {:ok, %{state | phase: :active, route_id: result.source_route_id, limits: result.limits}} + else + {:error, code} -> close(code, state) + false -> close(:ticket_expired, state) + _other -> close(:service_unavailable, state) + end + end + + def handle_in({message, opcode: :binary}, %{phase: :active} = state) do + with true <- byte_size(message) <= state.limits.max_frame_bytes, + {:ok, %{kind: :send} = frame} <- Frame.decode(message), + {:ok, rate_state} <- rate_limit(state, byte_size(message)) do + admitted = receipt(frame.attempt_id, :admitted) + + case RouteRegistry.forward( + state.registry, + state.route_id, + frame.route_id, + frame.attempt_id, + frame.payload + ) do + :ok -> + {:push, [binary: admitted, binary: receipt(frame.attempt_id, :forwarded)], rate_state} + + {:error, code} -> + {:push, [binary: admitted, binary: failure(frame.attempt_id, code)], rate_state} + end + else + {:error, :rate_limited} -> close(:rate_limited, state) + _other -> close(:bad_frame, state) + end + end + + def handle_in(_frame, state), do: close(:bad_frame, state) + + @impl true + def handle_control({_payload, opcode: opcode}, state) when opcode in [:ping, :pong], + do: {:ok, state} + + @impl true + def handle_info( + {:relay_delivery, source_route_id, attempt_id, payload, queued_bytes}, + %{phase: :active} = state + ) do + encoded = + encode!(%{ + kind: :delivery, + attempt_id: attempt_id, + route_id: source_route_id, + payload: payload + }) + + send(self(), {:delivery_handed_to_socket, queued_bytes}) + {:push, {:binary, encoded}, state} + end + + def handle_info({:delivery_handed_to_socket, queued_bytes}, %{phase: :active} = state) do + RouteRegistry.delivered(state.registry, state.route_id, queued_bytes) + {:ok, state} + end + + def handle_info(:heartbeat, %{phase: :active} = state) do + Process.send_after(self(), :heartbeat, state.limits.heartbeat_interval_ms) + {:push, {:ping, <<>>}, state} + end + + def handle_info(:lease_expired, state), do: close(:unauthorized, state) + def handle_info(:route_revoked, state), do: close(:unauthorized, state) + def handle_info(:relay_draining, state), do: close(:service_unavailable, state) + + def handle_info(:admission_timeout, %{phase: :awaiting_admission} = state), + do: close(:unauthorized, state) + + def handle_info(:admission_timeout, state), do: {:ok, state} + def handle_info(_message, state), do: {:ok, state} + + @impl true + def terminate(_reason, %{route_id: nil}), do: :ok + + def terminate(_reason, state) do + RouteRegistry.unregister(state.registry, state.route_id) + :ok + end + + defp rate_limit(state, bytes) do + now = System.monotonic_time(:millisecond) + + current = + if now - state.rate_window_started >= @rate_window_ms do + %{state | rate_window_started: now, rate_frames: 0, rate_bytes: 0} + else + state + end + + max_bytes = current.limits.max_frame_bytes * @max_frames_per_window + + if current.rate_frames + 1 > @max_frames_per_window or current.rate_bytes + bytes > max_bytes do + {:error, :rate_limited} + else + {:ok, + %{ + current + | rate_frames: current.rate_frames + 1, + rate_bytes: current.rate_bytes + bytes + }} + end + end + + defp receipt(attempt_id, status) do + encode!(%{kind: :receipt, attempt_id: attempt_id, status: status}) + end + + defp failure(attempt_id, code) do + encode!(%{kind: :failure, attempt_id: attempt_id, code: code}) + end + + defp encode!(frame) do + {:ok, encoded} = Frame.encode(frame) + encoded + end + + defp close(code, state), + do: {:stop, :normal, {1008, Atom.to_string(code)}, state} +end diff --git a/services/relay/lib/axl_relay/frame.ex b/services/relay/lib/axl_relay/frame.ex new file mode 100644 index 00000000..54e1b661 --- /dev/null +++ b/services/relay/lib/axl_relay/frame.ex @@ -0,0 +1,168 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.Frame do + @moduledoc "Bounded transport-v1 framing for opaque relay payloads." + + @magic "AXLR" + @transport_version 1 + @max_frame_bytes 65_535 + @routed_header_bytes 42 + @max_payload_bytes @max_frame_bytes - @routed_header_bytes + @failure_codes [ + :bad_frame, + :unsupported_transport_version, + :unauthorized, + :forbidden_route, + :ticket_expired, + :ticket_consumed, + :destination_offline, + :rate_limited, + :queue_full, + :slow_consumer, + :service_unavailable + ] + + @type relay_frame :: + %{ + kind: :send | :delivery, + attempt_id: String.t(), + route_id: String.t(), + payload: binary() + } + | %{kind: :receipt, attempt_id: String.t(), status: :admitted | :forwarded} + | %{kind: :failure, attempt_id: String.t(), code: atom()} + + @spec max_frame_bytes() :: pos_integer() + def max_frame_bytes, do: @max_frame_bytes + + @spec max_payload_bytes() :: pos_integer() + def max_payload_bytes, do: @max_payload_bytes + + @spec decode(binary()) :: {:ok, relay_frame()} | {:error, atom()} + def decode(frame) when is_binary(frame) and byte_size(frame) <= @max_frame_bytes do + decode_bounded(frame) + end + + def decode(_frame), do: {:error, :bad_frame} + + defp decode_bounded(<<@magic, version, _rest::binary>>) when version != @transport_version, + do: {:error, :unsupported_transport_version} + + defp decode_bounded( + <<@magic, @transport_version, kind, attempt::binary-size(16), route::binary-size(16), + payload_size::unsigned-big-32, payload::binary>> + ) + when kind in [1, 2] and payload_size == byte_size(payload) do + with {:ok, attempt_id} <- decode_uuid(attempt), + {:ok, route_id} <- decode_uuid(route) do + {:ok, + %{ + kind: if(kind == 1, do: :send, else: :delivery), + attempt_id: attempt_id, + route_id: route_id, + payload: payload + }} + end + end + + defp decode_bounded(<<@magic, @transport_version, 3, attempt::binary-size(16), status>>) do + with {:ok, attempt_id} <- decode_uuid(attempt), + {:ok, decoded_status} <- decode_status(status) do + {:ok, %{kind: :receipt, attempt_id: attempt_id, status: decoded_status}} + end + end + + defp decode_bounded(<<@magic, @transport_version, 4, attempt::binary-size(16), code>>) do + with {:ok, attempt_id} <- decode_uuid(attempt), + {:ok, decoded_code} <- decode_failure(code) do + {:ok, %{kind: :failure, attempt_id: attempt_id, code: decoded_code}} + end + end + + defp decode_bounded(<<@magic, @transport_version, _rest::binary>>), do: {:error, :bad_frame} + defp decode_bounded(_frame), do: {:error, :bad_frame} + + @spec encode(relay_frame()) :: {:ok, binary()} | {:error, :bad_frame} + def encode(%{kind: kind, attempt_id: attempt_id, route_id: route_id, payload: payload}) + when kind in [:send, :delivery] and is_binary(payload) and + byte_size(payload) <= @max_payload_bytes do + with {:ok, attempt} <- encode_uuid(attempt_id), + {:ok, route} <- encode_uuid(route_id) do + kind_byte = if kind == :send, do: 1, else: 2 + + {:ok, + <<@magic, @transport_version, kind_byte, attempt::binary, route::binary, + byte_size(payload)::unsigned-big-32, payload::binary>>} + end + end + + def encode(%{kind: :receipt, attempt_id: attempt_id, status: status}) do + with {:ok, attempt} <- encode_uuid(attempt_id), + {:ok, status_byte} <- encode_status(status) do + {:ok, <<@magic, @transport_version, 3, attempt::binary, status_byte>>} + end + end + + def encode(%{kind: :failure, attempt_id: attempt_id, code: code}) do + with {:ok, attempt} <- encode_uuid(attempt_id), + {:ok, code_byte} <- encode_failure(code) do + {:ok, <<@magic, @transport_version, 4, attempt::binary, code_byte>>} + end + end + + def encode(_frame), do: {:error, :bad_frame} + + defp decode_status(1), do: {:ok, :admitted} + defp decode_status(2), do: {:ok, :forwarded} + defp decode_status(_status), do: {:error, :bad_frame} + + defp encode_status(:admitted), do: {:ok, 1} + defp encode_status(:forwarded), do: {:ok, 2} + defp encode_status(_status), do: {:error, :bad_frame} + + defp decode_failure(value) when value in 1..length(@failure_codes)//1 do + {:ok, Enum.fetch!(@failure_codes, value - 1)} + end + + defp decode_failure(_value), do: {:error, :bad_frame} + + defp encode_failure(code) do + case Enum.find_index(@failure_codes, &(&1 == code)) do + nil -> {:error, :bad_frame} + index -> {:ok, index + 1} + end + end + + defp encode_uuid(value) when is_binary(value) do + case Base.decode16(String.replace(value, "-", ""), case: :lower) do + {:ok, bytes} when byte_size(bytes) == 16 -> decode_uuid(bytes, bytes) + _other -> {:error, :bad_frame} + end + end + + defp encode_uuid(_value), do: {:error, :bad_frame} + + defp decode_uuid(bytes), do: decode_uuid(bytes, format_uuid(bytes)) + + defp decode_uuid(<<_::48, version::4, _::12, 2::2, _::62>>, result) + when version >= 1 and version <= 8, + do: {:ok, result} + + defp decode_uuid(_bytes, _result), do: {:error, :bad_frame} + + defp format_uuid(bytes) do + hex = Base.encode16(bytes, case: :lower) + + Enum.join( + [ + binary_part(hex, 0, 8), + binary_part(hex, 8, 4), + binary_part(hex, 12, 4), + binary_part(hex, 16, 4), + binary_part(hex, 20, 12) + ], + "-" + ) + end +end diff --git a/services/relay/lib/axl_relay/http_control_plane_client.ex b/services/relay/lib/axl_relay/http_control_plane_client.ex new file mode 100644 index 00000000..08cf5446 --- /dev/null +++ b/services/relay/lib/axl_relay/http_control_plane_client.ex @@ -0,0 +1,167 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.HttpControlPlaneClient do + @moduledoc "HTTP implementation of the authenticated control-plane admission boundary." + + @behaviour AxlRelay.ControlPlaneClient + + @impl true + def consume_ticket(admission, relay_instance_id, options) do + with {:ok, origin} <- Keyword.fetch(options, :origin), + true <- valid_origin?(origin), + {:ok, headers} when headers != [] <- Keyword.fetch(options, :headers), + body <- + :json.encode(%{ + "version" => 1, + "ticket" => admission["ticket"], + "relayInstanceId" => relay_instance_id, + "connectionNonce" => admission["connectionNonce"], + "possessionProof" => admission["possessionProof"] + }) + |> IO.iodata_to_binary(), + {:ok, response} <- post(origin, headers, body), + {:ok, result} <- validate_result(response) do + {:ok, result} + else + {:error, code} when is_atom(code) -> {:error, code} + _other -> {:error, :service_unavailable} + end + end + + defp valid_origin?(origin) when is_binary(origin) do + case URI.parse(origin) do + %URI{scheme: "https", host: host, path: path, query: nil, fragment: nil, userinfo: nil} + when is_binary(host) and path in [nil, ""] -> + true + + _other -> + false + end + end + + defp valid_origin?(_origin), do: false + + defp post(origin, headers, body) do + url = String.to_charlist(origin <> "/internal/v1/relay/tickets/consume") + request_headers = [{~c"content-type", ~c"application/json"} | headers] + request = {url, request_headers, ~c"application/json", body} + + case :httpc.request(:post, request, [timeout: 5_000, connect_timeout: 3_000], + body_format: :binary + ) do + {:ok, {{_http, 200, _reason}, _headers, response}} -> + decode_json(response) + + {:ok, {{_http, status, _reason}, _headers, response}} when status in [401, 409] -> + decode_error(response) + + _other -> + {:error, :service_unavailable} + end + end + + defp decode_json(body) do + {:ok, :json.decode(body)} + catch + _kind, _reason -> {:error, :service_unavailable} + end + + defp decode_error(body) do + with {:ok, %{"error" => %{"code" => code}}} <- decode_json(body), + mapped when not is_nil(mapped) <- + Map.get( + %{ + "unauthorized" => :unauthorized, + "ticket_expired" => :ticket_expired, + "ticket_consumed" => :ticket_consumed + }, + code + ) do + {:error, mapped} + else + _other -> {:error, :service_unavailable} + end + end + + @doc false + def validate_result(result) when is_map(result) do + required = + MapSet.new([ + "version", + "installationId", + "sourceRouteId", + "role", + "leaseExpiresAt", + "limits" + ]) + + allowed = MapSet.put(required, "deviceId") + keys = MapSet.new(Map.keys(result)) + + with true <- MapSet.subset?(required, keys) and MapSet.subset?(keys, allowed), + 1 <- result["version"], + true <- uuid?(result["installationId"]), + true <- uuid?(result["sourceRouteId"]), + role when role in ["daemon", "device"] <- result["role"], + true <- valid_device?(role, result["deviceId"]), + lease when is_integer(lease) and lease >= 0 <- result["leaseExpiresAt"], + {:ok, limits} <- validate_limits(result["limits"]) do + {:ok, + %{ + installation_id: result["installationId"], + device_id: result["deviceId"], + source_route_id: result["sourceRouteId"], + role: String.to_existing_atom(role), + lease_expires_at: lease, + limits: limits + }} + else + _other -> {:error, :service_unavailable} + end + end + + def validate_result(_result), do: {:error, :service_unavailable} + + defp validate_limits(limits) when is_map(limits) do + keys = + MapSet.new([ + "maxFrameBytes", + "maxQueuedBytes", + "heartbeatIntervalMs", + "idleTimeoutMs" + ]) + + with ^keys <- MapSet.new(Map.keys(limits)), + frame when is_integer(frame) and frame in 1..65_535 <- limits["maxFrameBytes"], + queued when is_integer(queued) and queued in 1..524_288 <- limits["maxQueuedBytes"], + heartbeat when is_integer(heartbeat) and heartbeat in 1..300_000 <- + limits["heartbeatIntervalMs"], + idle when is_integer(idle) and idle in 1..600_000 <- limits["idleTimeoutMs"] do + {:ok, + %{ + max_frame_bytes: frame, + max_queued_bytes: queued, + heartbeat_interval_ms: heartbeat, + idle_timeout_ms: idle + }} + else + _other -> {:error, :service_unavailable} + end + end + + defp validate_limits(_limits), do: {:error, :service_unavailable} + + defp valid_device?("daemon", nil), do: true + defp valid_device?("device", value), do: uuid?(value) + defp valid_device?(_role, _value), do: false + + defp uuid?(value) when is_binary(value) do + Regex.match?( + ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + value + ) + end + + defp uuid?(_value), do: false +end diff --git a/services/relay/lib/axl_relay/listener.ex b/services/relay/lib/axl_relay/listener.ex new file mode 100644 index 00000000..1ca0c91b --- /dev/null +++ b/services/relay/lib/axl_relay/listener.ex @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.Listener do + @moduledoc "Configured Bandit listener for the relay's public and internal boundaries." + + def child_spec(options) do + Bandit.child_spec(bandit_options(options)) + end + + def start_link(options) do + Bandit.start_link(bandit_options(options)) + end + + defp bandit_options(options) do + [ + plug: + {AxlRelay.Router, + [ + connection_options: Keyword.fetch!(options, :connection_options), + internal_authenticator: Keyword.fetch!(options, :internal_authenticator), + internal_authenticator_options: + Keyword.get(options, :internal_authenticator_options, []), + registry: Keyword.get(options, :registry, AxlRelay.RouteRegistry) + ]}, + scheme: Keyword.get(options, :scheme, :https), + ip: Keyword.get(options, :ip, {127, 0, 0, 1}), + port: Keyword.fetch!(options, :port), + startup_log: false + ] + end +end diff --git a/services/relay/lib/axl_relay/revocation_handler.ex b/services/relay/lib/axl_relay/revocation_handler.ex new file mode 100644 index 00000000..a39b095e --- /dev/null +++ b/services/relay/lib/axl_relay/revocation_handler.ex @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.InternalAuthenticator do + @moduledoc "Injected authentication boundary for control-plane callbacks." + + @callback authenticate(Plug.Conn.t(), binary(), keyword()) :: boolean() +end + +defmodule AxlRelay.RevocationHandler do + @moduledoc "Runtime validation for best-effort route revocation." + + @doc false + def parse_notification(body) do + with decoded when is_map(decoded) <- :json.decode(body), + required <- MapSet.new(["version", "installationId", "generation", "effectiveAt"]), + allowed <- MapSet.put(required, "deviceId"), + keys <- MapSet.new(Map.keys(decoded)), + true <- MapSet.subset?(required, keys) and MapSet.subset?(keys, allowed), + 1 <- decoded["version"], + true <- uuid?(decoded["installationId"]), + true <- is_nil(decoded["deviceId"]) or uuid?(decoded["deviceId"]), + generation when is_integer(generation) and generation > 0 <- decoded["generation"], + effective_at when is_integer(effective_at) and effective_at >= 0 <- + decoded["effectiveAt"] do + {:ok, + %{ + installation_id: decoded["installationId"], + device_id: decoded["deviceId"], + generation: generation, + effective_at: effective_at + }} + else + _other -> {:error, :bad_request} + end + catch + _kind, _reason -> {:error, :bad_request} + end + + defp uuid?(value) when is_binary(value) do + Regex.match?( + ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + value + ) + end + + defp uuid?(_value), do: false +end diff --git a/services/relay/lib/axl_relay/route_registry.ex b/services/relay/lib/axl_relay/route_registry.ex new file mode 100644 index 00000000..6c3db1c6 --- /dev/null +++ b/services/relay/lib/axl_relay/route_registry.ex @@ -0,0 +1,205 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.RouteRegistry do + @moduledoc "In-memory, installation-scoped route table with bounded pending bytes." + + use GenServer + + @type admission :: %{ + installation_id: String.t(), + device_id: String.t() | nil, + source_route_id: String.t(), + limits: %{max_queued_bytes: pos_integer()} + } + + def start_link(options \\ []) do + case Keyword.get(options, :name, __MODULE__) do + nil -> GenServer.start_link(__MODULE__, options) + name -> GenServer.start_link(__MODULE__, options, name: name) + end + end + + def register(server \\ __MODULE__, pid, admission) do + GenServer.call(server, {:register, pid, admission}) + end + + def unregister(server \\ __MODULE__, route_id) do + GenServer.call(server, {:unregister, route_id}) + end + + def forward(server \\ __MODULE__, source_route_id, destination_route_id, attempt_id, payload) do + GenServer.call( + server, + {:forward, source_route_id, destination_route_id, attempt_id, payload} + ) + end + + def delivered(server \\ __MODULE__, route_id, bytes) do + GenServer.cast(server, {:delivered, route_id, bytes}) + end + + def revoke(server \\ __MODULE__, notification) do + GenServer.call(server, {:revoke, notification}) + end + + def drain(server \\ __MODULE__) do + GenServer.call(server, :drain) + end + + def snapshot(server \\ __MODULE__) do + GenServer.call(server, :snapshot) + end + + @impl true + def init(_options) do + {:ok, %{routes: %{}, monitors: %{}, generations: %{}, draining: false}} + end + + @impl true + def handle_call({:register, _pid, _admission}, _from, %{draining: true} = state) do + {:reply, {:error, :service_unavailable}, state} + end + + def handle_call({:register, pid, admission}, _from, state) do + route_id = admission.source_route_id + + if Map.has_key?(state.routes, route_id) do + {:reply, {:error, :forbidden_route}, state} + else + monitor = Process.monitor(pid) + route = Map.merge(admission, %{pid: pid, monitor: monitor, queued_bytes: 0}) + + {:reply, :ok, + %{ + state + | routes: Map.put(state.routes, route_id, route), + monitors: Map.put(state.monitors, monitor, route_id) + }} + end + end + + def handle_call({:unregister, route_id}, _from, state) do + {:reply, :ok, remove_route(state, route_id)} + end + + def handle_call( + {:forward, source_route_id, destination_route_id, attempt_id, payload}, + _from, + state + ) do + source = state.routes[source_route_id] + destination = state.routes[destination_route_id] + queued_bytes = byte_size(payload) + 42 + + cond do + source == nil -> + {:reply, {:error, :unauthorized}, state} + + destination == nil -> + {:reply, {:error, :destination_offline}, state} + + source.installation_id != destination.installation_id -> + {:reply, {:error, :forbidden_route}, state} + + destination.queued_bytes + queued_bytes > destination.limits.max_queued_bytes -> + {:reply, {:error, :queue_full}, state} + + true -> + send( + destination.pid, + {:relay_delivery, source_route_id, attempt_id, payload, queued_bytes} + ) + + next_state = + put_in( + state, + [:routes, destination_route_id, :queued_bytes], + destination.queued_bytes + queued_bytes + ) + + {:reply, :ok, next_state} + end + end + + def handle_call({:revoke, notification}, _from, state) do + key = {notification.installation_id, notification.device_id || :all} + previous = Map.get(state.generations, key, 0) + + if notification.generation <= previous do + {:reply, :ok, state} + else + matching = + state.routes + |> Enum.filter(fn {_route_id, route} -> + route.installation_id == notification.installation_id and + (notification.device_id == nil or route.device_id == notification.device_id) + end) + + Enum.each(matching, fn {_route_id, route} -> send(route.pid, :route_revoked) end) + + next_state = + Enum.reduce(matching, state, fn {route_id, _route}, current -> + remove_route(current, route_id) + end) + + {:reply, :ok, + %{next_state | generations: Map.put(next_state.generations, key, notification.generation)}} + end + end + + def handle_call(:drain, _from, state) do + Enum.each(state.routes, fn {_route_id, route} -> send(route.pid, :relay_draining) end) + {:reply, :ok, %{state | draining: true}} + end + + def handle_call(:snapshot, _from, state) do + routes = + Map.new(state.routes, fn {route_id, route} -> + {route_id, + %{ + installation_id: route.installation_id, + device_id: route.device_id, + queued_bytes: route.queued_bytes + }} + end) + + {:reply, %{routes: routes, draining: state.draining}, state} + end + + @impl true + def handle_cast({:delivered, route_id, bytes}, state) do + case state.routes[route_id] do + nil -> + {:noreply, state} + + route -> + next_state = + put_in(state, [:routes, route_id, :queued_bytes], max(0, route.queued_bytes - bytes)) + + {:noreply, next_state} + end + end + + @impl true + def handle_info({:DOWN, monitor, :process, _pid, _reason}, state) do + case Map.pop(state.monitors, monitor) do + {nil, _monitors} -> + {:noreply, state} + + {route_id, monitors} -> + {:noreply, %{state | routes: Map.delete(state.routes, route_id), monitors: monitors}} + end + end + + defp remove_route(state, route_id) do + case Map.pop(state.routes, route_id) do + {nil, _routes} -> + state + + {route, routes} -> + Process.demonitor(route.monitor, [:flush]) + %{state | routes: routes, monitors: Map.delete(state.monitors, route.monitor)} + end + end +end diff --git a/services/relay/lib/axl_relay/router.ex b/services/relay/lib/axl_relay/router.ex new file mode 100644 index 00000000..c2da801f --- /dev/null +++ b/services/relay/lib/axl_relay/router.ex @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.Router do + @moduledoc "Plug boundary for WebSocket admission and authenticated revocation." + + import Plug.Conn + + @behaviour Plug + @max_body_bytes 4_096 + + @impl true + def init(options), do: options + + @impl true + def call(%{method: "GET", path_info: ["v1", "connect"]} = connection, options) do + connection + |> WebSockAdapter.upgrade( + AxlRelay.Connection, + Keyword.fetch!(options, :connection_options), + compress: false, + timeout: 60_000, + max_frame_size: AxlRelay.Frame.max_frame_bytes() + ) + |> halt() + end + + def call( + %{method: "POST", path_info: ["internal", "v1", "revocations"]} = connection, + options + ) do + with {:ok, body, connection} <- + read_body(connection, length: @max_body_bytes, read_length: @max_body_bytes), + authenticator <- Keyword.fetch!(options, :internal_authenticator), + true <- + authenticator.authenticate( + connection, + body, + Keyword.get(options, :internal_authenticator_options, []) + ), + {:ok, notification} <- AxlRelay.RevocationHandler.parse_notification(body), + :ok <- + AxlRelay.RouteRegistry.revoke( + Keyword.get(options, :registry, AxlRelay.RouteRegistry), + notification + ) do + json(connection, 200, %{"version" => 1, "accepted" => true}) + else + false -> + json(connection, 401, %{"error" => %{"code" => "unauthorized"}}) + + {:more, _body, connection} -> + json(connection, 413, %{"error" => %{"code" => "bad_request"}}) + + _other -> + json(connection, 400, %{"error" => %{"code" => "bad_request"}}) + end + end + + def call(connection, _options) do + status = if connection.method in ["GET", "POST"], do: 404, else: 405 + json(connection, status, %{"error" => %{"code" => "not_found"}}) + end + + defp json(connection, status, body) do + encoded = body |> :json.encode() |> IO.iodata_to_binary() + + connection + |> put_resp_header("cache-control", "no-store") + |> put_resp_header("content-type", "application/json; charset=utf-8") + |> put_resp_header("x-content-type-options", "nosniff") + |> send_resp(status, encoded) + |> halt() + end +end diff --git a/services/relay/mix.exs b/services/relay/mix.exs new file mode 100644 index 00000000..84f1b0e6 --- /dev/null +++ b/services/relay/mix.exs @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.MixProject do + use Mix.Project + + def project do + [ + app: :axl_relay, + version: "0.1.0", + elixir: "~> 1.18", + start_permanent: Mix.env() == :prod, + deps: deps(), + dialyzer: [plt_add_apps: [:bandit, :inets, :ssl]] + ] + end + + def application do + [ + extra_applications: [:logger, :inets, :ssl], + mod: {AxlRelay.Application, []} + ] + end + + defp deps do + [ + {:bandit, "1.12.5"}, + {:plug, "1.20.3"}, + {:websock_adapter, "0.6.0"}, + {:credo, "1.7.12", only: [:dev, :test], runtime: false}, + {:dialyxir, "1.4.6", only: [:dev, :test], runtime: false}, + {:mix_audit, "2.1.5", only: [:dev, :test], runtime: false} + ] + end +end diff --git a/services/relay/mix.lock b/services/relay/mix.lock new file mode 100644 index 00000000..d364c596 --- /dev/null +++ b/services/relay/mix.lock @@ -0,0 +1,20 @@ +%{ + "bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, + "dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"}, + "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, + "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, + "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, + "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, + "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"}, +} diff --git a/services/relay/test/frame_test.exs b/services/relay/test/frame_test.exs new file mode 100644 index 00000000..3669c371 --- /dev/null +++ b/services/relay/test/frame_test.exs @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.FrameTest do + use ExUnit.Case, async: true + + alias AxlRelay.Frame + + @fixture_path Path.expand( + "../../../packages/protocol/test/fixtures/remote-transport-v1.json", + __DIR__ + ) + @fixtures @fixture_path |> File.read!() |> :json.decode() + + test "accepts and reproduces the TypeScript canonical frames" do + for fixture <- @fixtures["accepted"] do + bytes = Base.decode64!(fixture["base64"]) + assert {:ok, frame} = Frame.decode(bytes), fixture["name"] + assert fixture_shape(frame) == fixture["frame"], fixture["name"] + assert {:ok, ^bytes} = Frame.encode(frame), fixture["name"] + end + end + + test "rejects every malformed canonical frame" do + for fixture <- @fixtures["rejected"] do + bytes = Base.decode64!(fixture["base64"]) + assert {:error, _reason} = Frame.decode(bytes), fixture["name"] + end + end + + test "rejects an oversized frame before parsing" do + assert {:error, :bad_frame} = Frame.decode(:binary.copy(<<0>>, Frame.max_frame_bytes() + 1)) + end + + defp fixture_shape(%{kind: kind, attempt_id: attempt_id, route_id: route_id, payload: payload}) do + %{ + "kind" => Atom.to_string(kind), + "attemptId" => attempt_id, + "routeId" => route_id, + "opaquePayloadBase64" => Base.encode64(payload) + } + end + + defp fixture_shape(%{kind: :receipt, attempt_id: attempt_id, status: status}) do + %{ + "kind" => "receipt", + "attemptId" => attempt_id, + "status" => Atom.to_string(status) + } + end + + defp fixture_shape(%{kind: :failure, attempt_id: attempt_id, code: code}) do + %{ + "kind" => "failure", + "attemptId" => attempt_id, + "code" => Atom.to_string(code) + } + end +end diff --git a/services/relay/test/internal_contract_test.exs b/services/relay/test/internal_contract_test.exs new file mode 100644 index 00000000..70104d9e --- /dev/null +++ b/services/relay/test/internal_contract_test.exs @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.InternalContractTest do + use ExUnit.Case, async: true + + alias AxlRelay.{Admission, HttpControlPlaneClient, RevocationHandler} + + @fixture_path Path.expand( + "../../../packages/protocol/test/fixtures/internal-relay-api-v1.json", + __DIR__ + ) + @fixtures @fixture_path |> File.read!() |> :json.decode() + + test "accepts the TypeScript ticket-consumption fixture" do + result = @fixtures["consumeTicket"]["result"] + + assert {:ok, parsed} = HttpControlPlaneClient.validate_result(result) + assert parsed.installation_id == result["installationId"] + assert parsed.device_id == result["deviceId"] + assert parsed.source_route_id == result["sourceRouteId"] + assert parsed.role == :device + assert parsed.limits.max_frame_bytes == 65_535 + assert parsed.limits.max_queued_bytes == 524_288 + end + + test "forms the admitted WebSocket message without relay-owned fields" do + consume = @fixtures["consumeTicket"]["request"] + + admission = + Map.take(consume, ["version", "ticket", "connectionNonce", "possessionProof"]) + |> :json.encode() + |> IO.iodata_to_binary() + + assert {:ok, parsed} = Admission.parse(admission) + assert parsed["ticket"] == consume["ticket"] + refute Map.has_key?(parsed, "relayInstanceId") + end + + test "accepts the TypeScript revocation fixture and rejects unknown fields" do + request = @fixtures["revocation"]["request"] + bytes = request |> :json.encode() |> IO.iodata_to_binary() + + assert {:ok, parsed} = RevocationHandler.parse_notification(bytes) + assert parsed.installation_id == request["installationId"] + assert parsed.device_id == request["deviceId"] + assert parsed.generation == request["generation"] + + malformed = request |> Map.put("unexpected", true) |> :json.encode() |> IO.iodata_to_binary() + assert {:error, :bad_request} = RevocationHandler.parse_notification(malformed) + end +end diff --git a/services/relay/test/route_registry_test.exs b/services/relay/test/route_registry_test.exs new file mode 100644 index 00000000..2812c611 --- /dev/null +++ b/services/relay/test/route_registry_test.exs @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.RouteRegistryTest do + use ExUnit.Case, async: true + + alias AxlRelay.RouteRegistry + + @installation "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + @source "11111111-1111-4111-8111-111111111111" + @destination "22222222-2222-4222-8222-222222222222" + @attempt "33333333-3333-4333-8333-333333333333" + + setup do + registry = start_supervised!({RouteRegistry, name: nil}) + parent = self() + + source = spawn_link(fn -> forward_messages(parent, :source) end) + destination = spawn_link(fn -> forward_messages(parent, :destination) end) + + limits = %{max_queued_bytes: 50} + + assert :ok = + RouteRegistry.register(registry, source, %{ + installation_id: @installation, + device_id: nil, + source_route_id: @source, + limits: limits + }) + + assert :ok = + RouteRegistry.register(registry, destination, %{ + installation_id: @installation, + device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + source_route_id: @destination, + limits: limits + }) + + %{registry: registry, destination: destination, source: source} + end + + test "routes only inside one installation and bounds pending bytes", %{registry: registry} do + assert :ok = RouteRegistry.forward(registry, @source, @destination, @attempt, <<1, 2, 3>>) + + assert_receive {:destination, {:relay_delivery, @source, @attempt, <<1, 2, 3>>, 45}} + + assert {:error, :queue_full} = + RouteRegistry.forward(registry, @source, @destination, @attempt, <<1, 2, 3>>) + + RouteRegistry.delivered(registry, @destination, 45) + + assert_eventually(fn -> + RouteRegistry.snapshot(registry).routes[@destination].queued_bytes == 0 + end) + + other_route = "44444444-4444-4444-8444-444444444444" + parent = self() + other = spawn_link(fn -> forward_messages(parent, :other) end) + + assert :ok = + RouteRegistry.register(registry, other, %{ + installation_id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + device_id: nil, + source_route_id: other_route, + limits: %{max_queued_bytes: 50} + }) + + assert {:error, :forbidden_route} = + RouteRegistry.forward(registry, @source, other_route, @attempt, <<1>>) + end + + test "revocation closes matching routes and draining rejects admission", %{registry: registry} do + assert :ok = + RouteRegistry.revoke(registry, %{ + installation_id: @installation, + device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + generation: 1 + }) + + assert_receive {:destination, :route_revoked} + refute Map.has_key?(RouteRegistry.snapshot(registry).routes, @destination) + + assert :ok = RouteRegistry.drain(registry) + assert_receive {:source, :relay_draining} + + assert {:error, :service_unavailable} = + RouteRegistry.register(registry, self(), %{ + installation_id: @installation, + device_id: nil, + source_route_id: "55555555-5555-4555-8555-555555555555", + limits: %{max_queued_bytes: 50} + }) + end + + defp forward_messages(parent, label) do + receive do + message -> + send(parent, {label, message}) + forward_messages(parent, label) + end + end + + defp assert_eventually(assertion, attempts \\ 20) + + defp assert_eventually(assertion, attempts) when attempts > 0 do + if assertion.() do + :ok + else + Process.sleep(5) + assert_eventually(assertion, attempts - 1) + end + end + + defp assert_eventually(_assertion, 0), do: flunk("condition did not become true") +end diff --git a/services/relay/test/test_helper.exs b/services/relay/test/test_helper.exs new file mode 100644 index 00000000..25e87925 --- /dev/null +++ b/services/relay/test/test_helper.exs @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +ExUnit.start() diff --git a/services/relay/test/websocket_relay_test.exs b/services/relay/test/websocket_relay_test.exs new file mode 100644 index 00000000..8f0c5bf7 --- /dev/null +++ b/services/relay/test/websocket_relay_test.exs @@ -0,0 +1,249 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.WebSocketRelayTest do + use ExUnit.Case, async: false + + alias AxlRelay.{Frame, Listener, RouteRegistry} + + @daemon_route "11111111-1111-4111-8111-111111111111" + @device_route "22222222-2222-4222-8222-222222222222" + @attempt "33333333-3333-4333-8333-333333333333" + + defmodule FakeControlPlane do + @behaviour AxlRelay.ControlPlaneClient + + @impl true + def consume_ticket(%{"ticket" => "unavailable"}, _relay_instance_id, _options), + do: {:error, :service_unavailable} + + def consume_ticket(%{"ticket" => ticket}, _relay_instance_id, options) + when ticket in ["daemon", "device"] do + role = if ticket == "daemon", do: :daemon, else: :device + route_id = Keyword.fetch!(options, role) + + {:ok, + %{ + installation_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + device_id: + if(ticket == "device", + do: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + else: nil + ), + source_route_id: route_id, + role: role, + lease_expires_at: System.system_time(:millisecond) + 60_000, + limits: %{ + max_frame_bytes: 65_535, + max_queued_bytes: 524_288, + heartbeat_interval_ms: 20_000, + idle_timeout_ms: 60_000 + } + }} + end + end + + defmodule FakeInternalAuthenticator do + @behaviour AxlRelay.InternalAuthenticator + + @impl true + def authenticate(connection, _body, _options) do + Plug.Conn.get_req_header(connection, "authorization") == ["Bearer internal-fixture"] + end + end + + setup do + registry = start_supervised!({RouteRegistry, name: nil}) + port = free_port() + + listener = + start_supervised!( + {Listener, + scheme: :http, + port: port, + ip: {127, 0, 0, 1}, + connection_options: [ + control_plane: FakeControlPlane, + control_plane_options: [daemon: @daemon_route, device: @device_route], + relay_instance_id: "relay-test", + registry: registry + ], + internal_authenticator: FakeInternalAuthenticator, + registry: registry} + ) + + %{listener: listener, registry: registry, port: port} + end + + test "admits two sockets and routes an opaque frame with distinct receipts", %{ + registry: registry, + port: port + } do + daemon = connect(port, "daemon") + device = connect(port, "device") + + assert_eventually(fn -> map_size(RouteRegistry.snapshot(registry).routes) == 2 end) + + assert {:ok, send_frame} = + Frame.encode(%{ + kind: :send, + attempt_id: @attempt, + route_id: @daemon_route, + payload: <<0, 1, 2, 255>> + }) + + :ok = :gen_tcp.send(device, client_binary_frame(send_frame)) + + assert {:ok, admitted} = device |> receive_binary_frame() |> Frame.decode() + assert admitted == %{kind: :receipt, attempt_id: @attempt, status: :admitted} + + assert {:ok, forwarded} = device |> receive_binary_frame() |> Frame.decode() + assert forwarded == %{kind: :receipt, attempt_id: @attempt, status: :forwarded} + + assert {:ok, delivery} = daemon |> receive_binary_frame() |> Frame.decode() + + assert delivery == %{ + kind: :delivery, + attempt_id: @attempt, + route_id: @device_route, + payload: <<0, 1, 2, 255>> + } + + :gen_tcp.close(device) + :gen_tcp.close(daemon) + end + + test "fails admission closed when the control plane is unavailable", %{ + registry: registry, + port: port + } do + socket = connect(port, "unavailable") + {:ok, <<0x88, _length>>} = :gen_tcp.recv(socket, 2, 2_000) + assert RouteRegistry.snapshot(registry).routes == %{} + :gen_tcp.close(socket) + end + + test "rejects unauthenticated revocation and applies an authenticated notification", %{ + registry: registry, + port: port + } do + route = "44444444-4444-4444-8444-444444444444" + + assert :ok = + RouteRegistry.register(registry, self(), %{ + installation_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + source_route_id: route, + limits: %{max_queued_bytes: 524_288} + }) + + body = + :json.encode(%{ + "version" => 1, + "installationId" => "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "deviceId" => "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "generation" => 1, + "effectiveAt" => 1_900_000_000_000 + }) + |> IO.iodata_to_binary() + + url = ~c"http://127.0.0.1:#{port}/internal/v1/revocations" + + assert {:ok, {{_version, 401, _reason}, _headers, _response}} = + :httpc.request(:post, {url, [], ~c"application/json", body}, [], []) + + headers = [{~c"authorization", ~c"Bearer internal-fixture"}] + + assert {:ok, {{_version, 200, _reason}, _headers, response}} = + :httpc.request(:post, {url, headers, ~c"application/json", body}, [], + body_format: :binary + ) + + assert :json.decode(response) == %{"version" => 1, "accepted" => true} + assert_receive :route_revoked + end + + defp free_port do + {:ok, socket} = :gen_tcp.listen(0, [:binary, ip: {127, 0, 0, 1}]) + {:ok, {_address, port}} = :inet.sockname(socket) + :gen_tcp.close(socket) + port + end + + defp connect(port, ticket) do + {:ok, socket} = :gen_tcp.connect({127, 0, 0, 1}, port, [:binary, active: false]) + + request = [ + "GET /v1/connect HTTP/1.1\r\n", + "Host: 127.0.0.1:", + Integer.to_string(port), + "\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n", + "Sec-WebSocket-Key: AAECAwQFBgcICQoLDA0ODw==\r\n", + "Sec-WebSocket-Version: 13\r\n\r\n" + ] + + :ok = :gen_tcp.send(socket, request) + {:ok, response} = :gen_tcp.recv(socket, 0, 2_000) + assert String.starts_with?(response, "HTTP/1.1 101") + + admission = + :json.encode(%{ + "version" => 1, + "ticket" => ticket, + "connectionNonce" => "fixture-nonce", + "possessionProof" => "AAECA/8=" + }) + |> IO.iodata_to_binary() + + :ok = :gen_tcp.send(socket, client_binary_frame(admission)) + socket + end + + defp client_binary_frame(payload) do + mask = <<1, 2, 3, 4>> + + encoded_length = + if byte_size(payload) < 126, + do: <<0x80 + byte_size(payload)>>, + else: <<0x80 + 126, byte_size(payload)::unsigned-big-16>> + + masked = + payload + |> :binary.bin_to_list() + |> Enum.with_index() + |> Enum.map(fn {byte, index} -> Bitwise.bxor(byte, :binary.at(mask, rem(index, 4))) end) + |> :binary.list_to_bin() + + <<0x82, encoded_length::binary, mask::binary, masked::binary>> + end + + defp receive_binary_frame(socket) do + {:ok, <<0x82, length>>} = :gen_tcp.recv(socket, 2, 2_000) + + size = + case length do + value when value < 126 -> + value + + 126 -> + {:ok, <>} = :gen_tcp.recv(socket, 2, 2_000) + value + end + + {:ok, payload} = :gen_tcp.recv(socket, size, 2_000) + payload + end + + defp assert_eventually(assertion, attempts \\ 40) + + defp assert_eventually(assertion, attempts) when attempts > 0 do + if assertion.() do + :ok + else + Process.sleep(5) + assert_eventually(assertion, attempts - 1) + end + end + + defp assert_eventually(_assertion, 0), do: flunk("condition did not become true") +end From 95eb04165868979395967812400ffb43d730595c Mon Sep 17 00:00:00 2001 From: Lokesh Date: Sat, 12 Sep 2026 17:29:59 +0400 Subject: [PATCH 04/16] docs(remote): record E2EE transport checkpoint Signed-off-by: Lokesh --- AGENTS.md | 1 + CODE_STRUCTURE.md | 21 +++- REUSE.toml | 23 +++- ROADMAP.md | 17 +++ docs/architecture/e2ee-transport-preflight.md | 108 ++++++++++++++++++ 5 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 docs/architecture/e2ee-transport-preflight.md diff --git a/AGENTS.md b/AGENTS.md index 0173542f..49b0d412 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,5 @@ + # Axl development guide diff --git a/CODE_STRUCTURE.md b/CODE_STRUCTURE.md index 6461c9d8..3f88723e 100644 --- a/CODE_STRUCTURE.md +++ b/CODE_STRUCTURE.md @@ -7,7 +7,7 @@ Status: working plan. This document accompanies [ROADMAP.md](ROADMAP.md) and [OPEN_SOURCE.md](OPEN_SOURCE.md). -Updated: 2026-08-28 +Updated: 2026-09-12 ## 1. Keep everything in one repository @@ -29,7 +29,8 @@ Codex offers a useful contrast. Its CLI and Rust core share a repository, while ## 2. Languages -- Use **TypeScript** for the kernel, protocol, daemon, adoption compiler, terminal client, web client, and extensions. It matches the ecosystems and standards Axl integrates with. +- Use **TypeScript** for the kernel, protocol, daemon, adoption compiler, terminal client, web client, extensions, and hosted control plane. It matches the ecosystems and standards Axl integrates with. +- Use **Elixir/OTP only for the hosted ciphertext relay** under `services/relay/`. The relay is a bounded transport process and must not own daemon, RPC, account, persistence, or cryptographic behavior. - Use **Kotlin with Jetpack Compose** for Android and **Swift with SwiftUI** for iOS. Choose protocol code generation when the first of these clients is built. - Do not add another application language. Tooling should use TypeScript or POSIX shell. @@ -51,6 +52,9 @@ axl/ ui/ # shared presentation tokens and React renderers sdk/ # shared TypeScript client SDK when multiple clients need it extensions/ # first-party extensions, one package per feature (roadmap ยง2.9) + services/ + control-plane/ # separately deployable TypeScript hosted control plane + relay/ # separately deployable Elixir/OTP opaque WebSocket relay apps/ android/ # Gradle project using the generated Kotlin SDK ios/ # Xcode project using the generated Swift SDK @@ -65,8 +69,11 @@ These rules keep package ownership clear: - `packages/protocol` has no runtime dependencies. - `packages/kernel` depends only on `packages/protocol` and Node.js built-ins. - First-party extensions use the same public extension API as third-party extensions. -- `packages/protocol` is the only source of wire-format truth. TypeScript definitions stay authoritative until a non-TypeScript client creates a real need for generation. +- `packages/protocol` is the only source of wire-format truth. TypeScript definitions stay authoritative until a non-TypeScript presentation client creates a real need for generation. The Elixir relay implements only its narrow transport and internal-service framing against canonical byte and JSON fixtures; it is not a daemon-protocol client. - Apps use the public protocol SDK rather than package internals. +- `services/control-plane` may depend on `packages/protocol`. It owns hosted account, installation, device, ticket, prekey, grant, upload-reservation, quota, and security-audit mutation. Identity providers, persistent datastores, and production service authentication stay behind injected interfaces until approved. +- `services/relay` consumes versioned language-neutral fixtures. It must not import TypeScript package internals, access the control-plane datastore, decrypt envelopes, interpret daemon RPC, persist canonical history, or store attachment bodies. It calls the authenticated control-plane admission API once per new connection and accepts authenticated revocation notifications. +- The control plane and relay are separate deployables. They share no private implementation imports and communicate only through their versioned internal HTTP contract. - `packages/runtime` assembles providers, tools, extensions, sandboxing, and the authoritative daemon without importing a presentation client. - `packages/tui` is a daemon client projection. It does not construct the runtime or depend at runtime on sandbox, kernel, or concrete extension implementations. It may depend on the dependency-free public `@axl/extension-api` for client-local presentation customization. - `packages/ui` owns shared presentation tokens and React renderers. It may depend only on `packages/sdk` and presentation libraries. It owns no daemon or process authority. @@ -80,7 +87,7 @@ The protocol package owns the contract between the daemon and every client. - TypeScript definitions are authoritative while all clients use TypeScript. - A schema change requires prior design discussion and compatibility notes. -- The first Swift or Kotlin client triggers a decision on the schema language and generator. +- The first Swift or Kotlin client triggers a decision on the schema language and generator. The relay's bounded outer-frame parser does not trigger client SDK generation because it does not parse daemon RPC or canonical events. - Generated SDKs then ship through their native package systems so external and in-tree clients use the same contract. ## 5. Independent implementation @@ -91,8 +98,8 @@ Any approved adaptation records its source, commit, and changes in an SPDX heade ## 6. Build tools -- Use pnpm workspaces for package management. Add a task runner with remote caching only when repository scale justifies it. -- Keep Gradle and Xcode native. CI coordinates the build systems but the JavaScript toolchain does not wrap them. +- Use pnpm workspaces for TypeScript package and service management. Add a task runner with remote caching only when repository scale justifies it. +- Keep Mix native for `services/relay`, and keep Gradle and Xcode native. CI coordinates the build systems but the JavaScript toolchain does not wrap them. - Version packages in `packages/` together. Mobile apps keep their own store versions. Bazel would add more contributor cost than value at the current scale. @@ -110,6 +117,8 @@ Bazel would add more contributor cost than value at the current scale. Every required check reports a result. Path filters decide whether the full job runs or a small gate job reports that no relevant files changed. - Kernel, protocol, and SDK changes run all builds, including both mobile apps. +- Control-plane changes run the root TypeScript checks and package-boundary checks. +- Relay or shared remote-fixture changes run Mix formatting, compilation with warnings as errors, tests, Credo, Dialyzer, dependency audit, cross-language fixture checks, package-boundary checks, and REUSE. - App-only changes run that app and lint checks. - Documentation and plan changes run formatting, link checking, and REUSE checks. - CodeQL, Gitleaks, and dependency review run for every merge candidate. diff --git a/REUSE.toml b/REUSE.toml index 58e69673..3ad289d7 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -1,4 +1,5 @@ # SPDX-FileCopyrightText: 2026 Hari Srinivasan +# SPDX-FileCopyrightText: 2026 Lokesh # SPDX-License-Identifier: Apache-2.0 version = 1 @@ -11,7 +12,6 @@ path = [ "LICENSES/Apache-2.0.txt", "biome.json", "distribution/npm/package.json", - "package.json", "packages/ai/tsconfig.build.json", "packages/ai/tsconfig.json", "packages/daemon/tsconfig.build.json", @@ -42,6 +42,14 @@ path = [ SPDX-FileCopyrightText = "2026 Hari Srinivasan" SPDX-License-Identifier = "Apache-2.0" +[[annotations]] +path = ["package.json"] +SPDX-FileCopyrightText = [ + "2026 Hari Srinivasan", + "2026 Lokesh", +] +SPDX-License-Identifier = "Apache-2.0" + [[annotations]] path = [ "packages/ai/package.json", @@ -60,6 +68,19 @@ SPDX-FileCopyrightText = [ ] SPDX-License-Identifier = "Apache-2.0" +[[annotations]] +path = [ + "packages/protocol/test/fixtures/internal-relay-api-v1.json", + "packages/protocol/test/fixtures/remote-transport-v1.json", + "services/control-plane/package.json", + "services/control-plane/tsconfig.build.json", + "services/control-plane/tsconfig.json", + "services/relay/.tool-versions", + "services/relay/mix.lock", +] +SPDX-FileCopyrightText = "2026 Lokesh" +SPDX-License-Identifier = "Apache-2.0" + [[annotations]] path = ["NOTICE"] SPDX-FileCopyrightText = [ diff --git a/ROADMAP.md b/ROADMAP.md index f7d96246..c0fefe28 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1361,6 +1361,10 @@ Requirements: The current mobile plan favors SwiftUI on iOS and Jetpack Compose on Android because native code supports Live Activities, Android foreground services, notification actions, widgets, share sheets, and efficient streaming text. This is not a binding stack decision. Choose the implementation when mobile work begins and its requirements are concrete. +Remote transport uses pairwise application-level E2EE in addition to TLS. The approved direction is PQXDH for asynchronous session establishment and Triple Ratchet for ongoing messages. This direction supersedes any earlier Noise selection. Production cryptography remains blocked on Person 1's security RFC, exact suite, reviewed library, secure-state design, interoperability fixtures, and independent security review. Transport code treats encrypted envelopes and public prekey bundles as bounded opaque bytes. The relay never imports the E2EE implementation or decrypts traffic. + +The managed path uses two separately deployable services: the TypeScript control plane owns hosted state and one-use admission, while the Elixir/OTP relay owns bounded in-memory WebSocket routing. The daemon remains the command and session authority. Transport proof uses only disposable sessions, a deterministic fake provider, opaque fixtures, and a test-only fake E2EE adapter. Ordinary-session steering and remote permission approval remain disabled until the E2EE and release gates pass. + #### 16.4 Headless and automation The same daemon serves non-interactive callers: @@ -2348,6 +2352,19 @@ The shared remote-connectivity and remote-web subsections are a scoped sequencin - [ ] Keep disconnected input as an explicit draft until the daemon durably accepts it; do not create a browser-authoritative prompt queue. - [ ] Support existing-session observation and steering first. Require a daemon-owned approved workspace identifier before creating a remote Code session. +The transport-first remote-control slice is an approved exception to phase ordering. It may establish service boundaries, opaque framing, one-use ticket admission, bounded relay routing, daemon authorization behind a test-only fake E2EE adapter, and reusable SDK delivery machinery. It must not implement cryptography, select production identity or storage infrastructure, enable ordinary-session remote access, or advertise production remote control. + +The integration base for this private slice is clean `main` commit `ea906d0295ba67f833c49ace408a9573551ea687` on `feature/e2ee-transport`. Stop for architecture review after the documentation, separate service boundaries, versioned fixture contract, atomic ticket-consumption path, and first bounded relay slice land. + +#### Remote transport preflight + +- [x] Record PQXDH plus Triple Ratchet as the approved direction and keep exact production cryptography blocked on Person 1's reviewed contract and library. +- [x] Add the separately deployable TypeScript control plane under `services/control-plane/` with authenticated ticket issuance and atomic one-use consumption through injected interfaces. +- [x] Add the separately deployable Elixir/OTP relay under `services/relay/` with authenticated admission, opaque bounded framing, in-memory installation-scoped routing, backpressure, heartbeat, lease, revocation, and draining behavior. +- [x] Publish language-neutral admission, revocation, and exact binary accept/reject fixtures consumed by both implementations. +- [x] Run TypeScript and Mix formatting, compilation, tests, static analysis, dependency auditing, package-boundary, and SPDX/REUSE checks in CI. +- [x] Stop at the architecture checkpoint before daemon, SDK, prekey, attachment, or production integration work. + #### Mobile clients - [ ] Choose mobile implementation stacks when work begins, based on concrete platform and product requirements. diff --git a/docs/architecture/e2ee-transport-preflight.md b/docs/architecture/e2ee-transport-preflight.md new file mode 100644 index 00000000..d6c23157 --- /dev/null +++ b/docs/architecture/e2ee-transport-preflight.md @@ -0,0 +1,108 @@ + + + +# E2EE transport preflight + +Status: architecture review checkpoint + +## Integration base + +The private implementation branch is `feature/e2ee-transport`, created from clean `main` commit `ea906d0295ba67f833c49ace408a9573551ea687`. + +## Scope + +This checkpoint proves bounded opaque transport. It does not provide E2EE or production remote control. + +Allowed work is limited to: + +- the TypeScript control-plane boundary and deterministic in-memory stores +- one-use relay tickets and authenticated internal admission +- the Elixir/OTP WebSocket relay +- opaque structural schemas and cross-language fixtures +- bounded routing, queues, heartbeat, lease expiry, revocation, draining, and rate limits +- later daemon authorization and SDK delivery tests behind a test-only fake E2EE adapter + +Person 1 exclusively owns PQXDH, Triple Ratchet, pairing cryptography, signatures, cryptographic prekey validation and consumption, cryptographic replay behavior, secure key and ratchet storage, encryption and decryption, associated data, attachment cryptography, and cryptographic test vectors. + +PQXDH plus Triple Ratchet is the approved direction and supersedes earlier Noise selections. No production cryptography may be implemented or enabled until Person 1 supplies an approved RFC, exact suite, reviewed library, secure-state contract, and interoperability fixtures and the integrated result passes independent review. + +## Service ownership + +`services/control-plane` is the only hosted component allowed to mutate account, installation, device, ticket, prekey, grant, upload-reservation, quota, and security-audit state. This slice implements ticket state only. Authentication, authorization, proof verification, clocks, and persistence are injected. Test adapters are deterministic and are not production defaults. + +`services/relay` owns ticket-authenticated WebSocket admission and bounded in-memory routing. It has no database access, E2EE dependency, RPC knowledge, canonical history, durable mailbox, or attachment storage. The relay derives the source route from consumed-ticket state and never accepts it from a sender. + +The daemon remains authoritative for grants, revocation, session authorization, idempotency, durable acceptance, canonical JSONL, and execution. Cryptographic authentication will identify a sender but will never authorize a command. + +## Internal service contract + +The relay sends `POST /internal/v1/relay/tickets/consume` once during admission. The exact JSON request and response fixture is `packages/protocol/test/fixtures/internal-relay-api-v1.json`. Binary proof bytes use canonical base64 in JSON. The control plane validates the body at runtime and atomically consumes one unexpired ticket. One concurrent consumer succeeds. Replays fail. + +The control plane sends `POST /internal/v1/revocations` to the relay. The same fixture defines its versioned request and response. Notifications are best effort. The daemon will still recheck current authority before durable command acceptance. + +Both HTTP boundaries require injected service authentication and fail closed when it is absent or rejects the request. This checkpoint does not select the production authentication mechanism. Tickets and internal credentials are forbidden in URLs, logs, metrics, and canonical events. + +If the control plane is unavailable, new admissions fail. Existing connections continue only through their consumed-ticket lease. + +## WebSocket admission + +Clients connect to `/v1/connect` with compression disabled. They do not put a ticket in the URL. The first binary message is bounded JSON with exactly: + +```json +{ + "version": 1, + "ticket": "opaque", + "connectionNonce": "opaque", + "possessionProof": "canonical-base64" +} +``` + +The relay adds its own instance ID and calls the control plane. Proof bytes and proof verification are fake and test-only in this checkpoint. No production proof construction is implied. + +## Binary relay framing + +`packages/protocol/test/fixtures/remote-transport-v1.json` is the byte-level cross-language fixture. Every integer is unsigned big-endian. UUIDs use their 16 RFC 9562 bytes. + +```text +bytes size field +0 4 ASCII AXLR +4 1 transport version (1) +5 1 kind: send=1, delivery=2, receipt=3, failure=4 +6 16 transport attempt UUID +``` + +Send and delivery continue with: + +```text +22 16 destination route for send; source route for delivery +38 4 opaque payload length +42 n opaque payload +``` + +Receipt and failure frames instead contain one byte at offset 22. Receipt values are `admitted=1` and `forwarded=2`. Failure values follow the order of `RELAY_FAILURE_CODES` in `packages/protocol/src/remote-transport.ts`, starting at 1. + +A complete WebSocket message, including this framing, is at most 65,535 bytes. Therefore the largest opaque payload is 65,493 bytes. The relay rejects oversized messages through the WebSocket parser ceiling and checks negotiated limits again before parsing or enqueueing. + +`attemptId` is transport-local. Retrying exact opaque bytes uses a new attempt ID while retaining the encrypted request and daemon idempotency identifiers inside the opaque payload. The relay does not define or inspect that payload. + +## Receipt meaning + +- `admitted`: the relay accepted one valid bounded frame. +- `forwarded`: the relay handed the bytes to the destination socket path. +- `daemon_accepted`: not a relay receipt. It is produced only after daemon authorization and durable acceptance. + +A client may remove a mutation from its durable outbox only after `daemon_accepted`. + +## Reviewed limits + +```text +maximum complete relay frame: 65,535 bytes +pending bytes per connection: 512 KiB +heartbeat interval: 20 seconds +idle timeout: 60 seconds +maximum ticket lifetime: 60 seconds +``` + +## Review boundary + +Stop here after the documentation, CI boundaries, fixtures, ticket-consumption path, and first bounded relay slice pass. Daemon authorization, SDK outbox behavior, prekey storage, S3 transport, real E2EE integration, ordinary-session steering, and permission approvals require the next reviewed milestone. From cfd99ba0ceb24d59ea6e118e4d3b887b2caa218e Mon Sep 17 00:00:00 2001 From: Lokesh Date: Sat, 12 Sep 2026 17:56:15 +0400 Subject: [PATCH 05/16] fix(protocol): simplify relay frame encoding Signed-off-by: Lokesh --- docs/architecture/e2ee-transport-preflight.md | 38 ++++++++++--- packages/protocol/src/remote-transport.ts | 53 +++++++++---------- .../test/fixtures/remote-transport-v1.json | 16 +++--- .../protocol/test/remote-transport.test.ts | 17 ++++++ services/relay/lib/axl_relay/frame.ex | 48 ++++++++--------- .../relay/lib/axl_relay/route_registry.ex | 2 +- services/relay/test/frame_test.exs | 25 +++++++++ services/relay/test/route_registry_test.exs | 4 +- 8 files changed, 134 insertions(+), 69 deletions(-) diff --git a/docs/architecture/e2ee-transport-preflight.md b/docs/architecture/e2ee-transport-preflight.md index d6c23157..d96fda8d 100644 --- a/docs/architecture/e2ee-transport-preflight.md +++ b/docs/architecture/e2ee-transport-preflight.md @@ -40,7 +40,17 @@ The relay sends `POST /internal/v1/relay/tickets/consume` once during admission. The control plane sends `POST /internal/v1/revocations` to the relay. The same fixture defines its versioned request and response. Notifications are best effort. The daemon will still recheck current authority before durable command acceptance. -Both HTTP boundaries require injected service authentication and fail closed when it is absent or rejects the request. This checkpoint does not select the production authentication mechanism. Tickets and internal credentials are forbidden in URLs, logs, metrics, and canonical events. +Both HTTP boundaries require injected service authentication and fail closed when it is absent or rejects the request. Tickets and internal credentials are forbidden in URLs, logs, metrics, and canonical events. + +Production service authentication is distinct from user authentication and ticket proof. It answers whether this exact relay instance may consume tickets and whether this exact control-plane instance may revoke routes. TLS without client authentication protects bytes in transit but does not establish that caller authority. The production mechanism remains an owner decision because it depends on deployment identity: + +- Prefer mutually authenticated TLS with short-lived workload certificates when both services have stable workload identities. +- A cloud-native signed workload token is acceptable when the selected platform provides audience-bound, short-lived service identities. +- Do not use a long-lived static bearer secret as the production design. +- Bind credentials to service role, environment, and endpoint audience. Rotate them without reconnecting existing leased clients. +- Authenticate the exact request body before parsing it, reject replays within the chosen mechanism, and redact all credential material. + +The current code therefore injects authentication on both sides and provides no production credential implementation. Selecting mTLS, SPIFFE, or a cloud IAM mechanism waits for the deployment decision. Tests use obvious fixture credentials only. If the control plane is unavailable, new admissions fail. Existing connections continue only through their consumed-ticket lease. @@ -75,24 +85,40 @@ Send and delivery continue with: ```text 22 16 destination route for send; source route for delivery -38 4 opaque payload length -42 n opaque payload +38 n opaque payload through the end of the WebSocket message +``` + +The revised encoding deliberately has no inner payload-length field. One binary WebSocket message is exactly one relay frame, so the WebSocket message boundary is authoritative. Removing the duplicate untrusted length avoids a second allocation decision and one class of inconsistent-length input. + +Receipt and failure frames instead contain one byte at offset 22. Receipt values are `admitted=1` and `forwarded=2`. Failure values are permanently assigned as follows: + +```text +1 bad_frame 7 destination_offline +2 unsupported_transport_version 8 rate_limited +3 unauthorized 9 queue_full +4 forbidden_route 10 slow_consumer +5 ticket_expired 11 service_unavailable +6 ticket_consumed ``` -Receipt and failure frames instead contain one byte at offset 22. Receipt values are `admitted=1` and `forwarded=2`. Failure values follow the order of `RELAY_FAILURE_CODES` in `packages/protocol/src/remote-transport.ts`, starting at 1. +These assignments must not be reordered. A new failure receives a new number or requires a transport-version change. -A complete WebSocket message, including this framing, is at most 65,535 bytes. Therefore the largest opaque payload is 65,493 bytes. The relay rejects oversized messages through the WebSocket parser ceiling and checks negotiated limits again before parsing or enqueueing. +A complete WebSocket message, including this framing, is at most 65,535 bytes. Therefore the largest opaque payload is 65,497 bytes. The relay rejects oversized messages through the WebSocket parser ceiling and checks negotiated limits again before parsing or enqueueing. `attemptId` is transport-local. Retrying exact opaque bytes uses a new attempt ID while retaining the encrypted request and daemon idempotency identifiers inside the opaque payload. The relay does not define or inspect that payload. ## Receipt meaning - `admitted`: the relay accepted one valid bounded frame. -- `forwarded`: the relay handed the bytes to the destination socket path. +- `forwarded`: the relay enqueued the bytes into the destination WebSocket process after route and queue checks. It does not prove a network write, endpoint receipt, parsing, decryption, or daemon acceptance. - `daemon_accepted`: not a relay receipt. It is produced only after daemon authorization and durable acceptance. A client may remove a mutation from its durable outbox only after `daemon_accepted`. +## Approved relay dependencies + +The first relay slice uses pinned Bandit, Plug, and WebSock Adapter production dependencies. They are approved for this boundary. Cowboy was evaluated and rejected after its locked version reported active security advisories. Credo, Dialyxir, and mix_audit are development-only checks. + ## Reviewed limits ```text diff --git a/packages/protocol/src/remote-transport.ts b/packages/protocol/src/remote-transport.ts index 1af9804a..37631054 100644 --- a/packages/protocol/src/remote-transport.ts +++ b/packages/protocol/src/remote-transport.ts @@ -7,7 +7,7 @@ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3} const base64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; const methodPattern = /^[a-z][a-z0-9]*(?:[._-][a-zA-Z0-9]+)*$/; const frameMagic = Uint8Array.of(0x41, 0x58, 0x4c, 0x52); -const routedFrameHeaderBytes = 42; +const routedFrameHeaderBytes = 38; const shortFrameBytes = 23; declare const installationIdBrand: unique symbol; @@ -109,21 +109,24 @@ export interface RelayReceipt { readonly status: RelayReceiptStatus; } -export const RELAY_FAILURE_CODES = [ - "bad_frame", - "unsupported_transport_version", - "unauthorized", - "forbidden_route", - "ticket_expired", - "ticket_consumed", - "destination_offline", - "rate_limited", - "queue_full", - "slow_consumer", - "service_unavailable", -] as const; - -export type RelayFailureCode = (typeof RELAY_FAILURE_CODES)[number]; +export const RELAY_FAILURE_CODE_VALUES = Object.freeze({ + bad_frame: 1, + unsupported_transport_version: 2, + unauthorized: 3, + forbidden_route: 4, + ticket_expired: 5, + ticket_consumed: 6, + destination_offline: 7, + rate_limited: 8, + queue_full: 9, + slow_consumer: 10, + service_unavailable: 11, +} as const); + +export type RelayFailureCode = keyof typeof RELAY_FAILURE_CODE_VALUES; +export const RELAY_FAILURE_CODES = Object.freeze( + Object.keys(RELAY_FAILURE_CODE_VALUES) as RelayFailureCode[], +); export interface RelayFailure { readonly transportVersion: typeof REMOTE_TRANSPORT_VERSION; @@ -557,7 +560,6 @@ function encodeRoutedFrame( const output = new Uint8Array(routedFrameHeaderBytes + payload.byteLength); writePrefix(output, kind, attemptId); output.set(uuidBytes(routeId), 22); - new DataView(output.buffer).setUint32(38, payload.byteLength, false); output.set(payload, routedFrameHeaderBytes); return output; } @@ -597,9 +599,9 @@ export function encodeRelayBinaryFrame(frame: RelayBinaryFrame): Uint8Array { case "failure": { const output = new Uint8Array(shortFrameBytes); writePrefix(output, 4, frame.attemptId); - const failureIndex = RELAY_FAILURE_CODES.indexOf((frame as RelayFailure).code); - if (failureIndex < 0) fail("frame.code", "is invalid"); - output[22] = failureIndex + 1; + const failureCode = RELAY_FAILURE_CODE_VALUES[(frame as RelayFailure).code]; + if (failureCode === undefined) fail("frame.code", "is invalid"); + output[22] = failureCode; return output; } } @@ -619,13 +621,6 @@ export function parseRelayBinaryFrame(value: Uint8Array): RelayBinaryFrame { if (kind === 1 || kind === 2) { if (value.byteLength < routedFrameHeaderBytes) fail("frame", "has a truncated routed header"); const routeId = parseRouteId(bytesUuid(value, 22, "frame.routeId")); - const payloadLength = new DataView(value.buffer, value.byteOffset, value.byteLength).getUint32( - 38, - false, - ); - if (payloadLength !== value.byteLength - routedFrameHeaderBytes) { - fail("frame.opaquePayload", "length does not match the frame size"); - } const opaquePayload = value.slice(routedFrameHeaderBytes); return kind === 1 ? { @@ -650,7 +645,9 @@ export function parseRelayBinaryFrame(value: Uint8Array): RelayBinaryFrame { if (kind === 4) { const failureByte = value[22]; if (failureByte === undefined) fail("frame.code", "is missing"); - const code = RELAY_FAILURE_CODES[failureByte - 1]; + const code = RELAY_FAILURE_CODES.find( + (candidate) => RELAY_FAILURE_CODE_VALUES[candidate] === failureByte, + ); if (code === undefined) fail("frame.code", "is invalid"); return { transportVersion: REMOTE_TRANSPORT_VERSION, attemptId, code }; } diff --git a/packages/protocol/test/fixtures/remote-transport-v1.json b/packages/protocol/test/fixtures/remote-transport-v1.json index 628d839a..54b29358 100644 --- a/packages/protocol/test/fixtures/remote-transport-v1.json +++ b/packages/protocol/test/fixtures/remote-transport-v1.json @@ -3,7 +3,7 @@ "accepted": [ { "name": "send", - "base64": "QVhMUgEBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAAAIAAEC/0FYTFI=", + "base64": "QVhMUgEBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAQL/QVhMUg==", "frame": { "kind": "send", "attemptId": "11111111-1111-4111-8111-111111111111", @@ -13,7 +13,7 @@ }, { "name": "delivery", - "base64": "QVhMUgECERERERERQRGBERERERERETMzMzMzM0MzgzMzMzMzMzMAAAAIAAEC/0FYTFI=", + "base64": "QVhMUgECERERERERQRGBERERERERETMzMzMzM0MzgzMzMzMzMzMAAQL/QVhMUg==", "frame": { "kind": "delivery", "attemptId": "11111111-1111-4111-8111-111111111111", @@ -43,12 +43,12 @@ "rejected": [ { "name": "wrong-magic", - "base64": "QlhMUgEBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAAAIAAEC/0FYTFI=", + "base64": "QlhMUgEBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAQL/QVhMUg==", "errorPath": "frame.magic" }, { "name": "unsupported-version", - "base64": "QVhMUgIBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAAAIAAEC/0FYTFI=", + "base64": "QVhMUgIBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAQL/QVhMUg==", "errorPath": "frame.transportVersion" }, { @@ -57,13 +57,13 @@ "errorPath": "frame" }, { - "name": "payload-length-mismatch", - "base64": "QVhMUgEBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAAAJAAEC/0FYTFI=", - "errorPath": "frame.opaquePayload" + "name": "invalid-attempt-id", + "base64": "QVhMUgEBERERERERARGBERERERERESIiIiIiIkIigiIiIiIiIiIAAQL/QVhMUg==", + "errorPath": "frame.attemptId" }, { "name": "unknown-kind", - "base64": "QVhMUgEJERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAAAIAAEC/0FYTFI=", + "base64": "QVhMUgEJERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAQL/QVhMUg==", "errorPath": "frame" } ] diff --git a/packages/protocol/test/remote-transport.test.ts b/packages/protocol/test/remote-transport.test.ts index 63b60f8e..be0c0e89 100644 --- a/packages/protocol/test/remote-transport.test.ts +++ b/packages/protocol/test/remote-transport.test.ts @@ -20,6 +20,7 @@ import { parseRelayBinaryFrame, parseRelayRevocationNotification, ProtocolValidationError, + RELAY_FAILURE_CODE_VALUES, REMOTE_TRANSPORT_VERSION, type RelayBinaryFrame, } from "../src/index.ts"; @@ -90,6 +91,22 @@ test("rejects every malformed canonical relay frame", () => { } }); +test("keeps relay failure byte assignments stable", () => { + assert.deepEqual(RELAY_FAILURE_CODE_VALUES, { + bad_frame: 1, + unsupported_transport_version: 2, + unauthorized: 3, + forbidden_route: 4, + ticket_expired: 5, + ticket_consumed: 6, + destination_offline: 7, + rate_limited: 8, + queue_full: 9, + slow_consumer: 10, + service_unavailable: 11, + }); +}); + test("enforces the complete frame bound before encoding", () => { const attemptId = "11111111-1111-4111-8111-111111111111" as const; const destinationRouteId = "22222222-2222-4222-8222-222222222222" as const; diff --git a/services/relay/lib/axl_relay/frame.ex b/services/relay/lib/axl_relay/frame.ex index 54e1b661..bd533099 100644 --- a/services/relay/lib/axl_relay/frame.ex +++ b/services/relay/lib/axl_relay/frame.ex @@ -7,21 +7,21 @@ defmodule AxlRelay.Frame do @magic "AXLR" @transport_version 1 @max_frame_bytes 65_535 - @routed_header_bytes 42 + @routed_header_bytes 38 @max_payload_bytes @max_frame_bytes - @routed_header_bytes - @failure_codes [ - :bad_frame, - :unsupported_transport_version, - :unauthorized, - :forbidden_route, - :ticket_expired, - :ticket_consumed, - :destination_offline, - :rate_limited, - :queue_full, - :slow_consumer, - :service_unavailable - ] + @failure_codes %{ + 1 => :bad_frame, + 2 => :unsupported_transport_version, + 3 => :unauthorized, + 4 => :forbidden_route, + 5 => :ticket_expired, + 6 => :ticket_consumed, + 7 => :destination_offline, + 8 => :rate_limited, + 9 => :queue_full, + 10 => :slow_consumer, + 11 => :service_unavailable + } @type relay_frame :: %{ @@ -51,9 +51,9 @@ defmodule AxlRelay.Frame do defp decode_bounded( <<@magic, @transport_version, kind, attempt::binary-size(16), route::binary-size(16), - payload_size::unsigned-big-32, payload::binary>> + payload::binary>> ) - when kind in [1, 2] and payload_size == byte_size(payload) do + when kind in [1, 2] do with {:ok, attempt_id} <- decode_uuid(attempt), {:ok, route_id} <- decode_uuid(route) do {:ok, @@ -92,8 +92,7 @@ defmodule AxlRelay.Frame do kind_byte = if kind == :send, do: 1, else: 2 {:ok, - <<@magic, @transport_version, kind_byte, attempt::binary, route::binary, - byte_size(payload)::unsigned-big-32, payload::binary>>} + <<@magic, @transport_version, kind_byte, attempt::binary, route::binary, payload::binary>>} end end @@ -121,16 +120,17 @@ defmodule AxlRelay.Frame do defp encode_status(:forwarded), do: {:ok, 2} defp encode_status(_status), do: {:error, :bad_frame} - defp decode_failure(value) when value in 1..length(@failure_codes)//1 do - {:ok, Enum.fetch!(@failure_codes, value - 1)} + defp decode_failure(value) do + case Map.fetch(@failure_codes, value) do + {:ok, code} -> {:ok, code} + :error -> {:error, :bad_frame} + end end - defp decode_failure(_value), do: {:error, :bad_frame} - defp encode_failure(code) do - case Enum.find_index(@failure_codes, &(&1 == code)) do + case Enum.find(@failure_codes, fn {_value, candidate} -> candidate == code end) do nil -> {:error, :bad_frame} - index -> {:ok, index + 1} + {value, _candidate} -> {:ok, value} end end diff --git a/services/relay/lib/axl_relay/route_registry.ex b/services/relay/lib/axl_relay/route_registry.ex index 6c3db1c6..0e8017e7 100644 --- a/services/relay/lib/axl_relay/route_registry.ex +++ b/services/relay/lib/axl_relay/route_registry.ex @@ -90,7 +90,7 @@ defmodule AxlRelay.RouteRegistry do ) do source = state.routes[source_route_id] destination = state.routes[destination_route_id] - queued_bytes = byte_size(payload) + 42 + queued_bytes = byte_size(payload) + 38 cond do source == nil -> diff --git a/services/relay/test/frame_test.exs b/services/relay/test/frame_test.exs index 3669c371..4cf56995 100644 --- a/services/relay/test/frame_test.exs +++ b/services/relay/test/frame_test.exs @@ -28,6 +28,31 @@ defmodule AxlRelay.FrameTest do end end + test "keeps failure byte assignments stable" do + codes = [ + bad_frame: 1, + unsupported_transport_version: 2, + unauthorized: 3, + forbidden_route: 4, + ticket_expired: 5, + ticket_consumed: 6, + destination_offline: 7, + rate_limited: 8, + queue_full: 9, + slow_consumer: 10, + service_unavailable: 11 + ] + + for {code, value} <- codes do + assert {:ok, <<"AXLR", 1, 4, _attempt::binary-size(16), ^value>>} = + Frame.encode(%{ + kind: :failure, + attempt_id: "11111111-1111-4111-8111-111111111111", + code: code + }) + end + end + test "rejects an oversized frame before parsing" do assert {:error, :bad_frame} = Frame.decode(:binary.copy(<<0>>, Frame.max_frame_bytes() + 1)) end diff --git a/services/relay/test/route_registry_test.exs b/services/relay/test/route_registry_test.exs index 2812c611..8c4ed0cf 100644 --- a/services/relay/test/route_registry_test.exs +++ b/services/relay/test/route_registry_test.exs @@ -42,12 +42,12 @@ defmodule AxlRelay.RouteRegistryTest do test "routes only inside one installation and bounds pending bytes", %{registry: registry} do assert :ok = RouteRegistry.forward(registry, @source, @destination, @attempt, <<1, 2, 3>>) - assert_receive {:destination, {:relay_delivery, @source, @attempt, <<1, 2, 3>>, 45}} + assert_receive {:destination, {:relay_delivery, @source, @attempt, <<1, 2, 3>>, 41}} assert {:error, :queue_full} = RouteRegistry.forward(registry, @source, @destination, @attempt, <<1, 2, 3>>) - RouteRegistry.delivered(registry, @destination, 45) + RouteRegistry.delivered(registry, @destination, 41) assert_eventually(fn -> RouteRegistry.snapshot(registry).routes[@destination].queued_bytes == 0 From 6ea73a07243a157be20d94a65c6f543ca60e8dec Mon Sep 17 00:00:00 2001 From: Lokesh Date: Sat, 12 Sep 2026 17:56:15 +0400 Subject: [PATCH 06/16] docs(remote): draft permission authorization contract Signed-off-by: Lokesh --- ROADMAP.md | 3 +- .../remote-permission-authorization.md | 222 ++++++++++++++++++ 2 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/remote-permission-authorization.md diff --git a/ROADMAP.md b/ROADMAP.md index c0fefe28..3798e7e6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1361,7 +1361,7 @@ Requirements: The current mobile plan favors SwiftUI on iOS and Jetpack Compose on Android because native code supports Live Activities, Android foreground services, notification actions, widgets, share sheets, and efficient streaming text. This is not a binding stack decision. Choose the implementation when mobile work begins and its requirements are concrete. -Remote transport uses pairwise application-level E2EE in addition to TLS. The approved direction is PQXDH for asynchronous session establishment and Triple Ratchet for ongoing messages. This direction supersedes any earlier Noise selection. Production cryptography remains blocked on Person 1's security RFC, exact suite, reviewed library, secure-state design, interoperability fixtures, and independent security review. Transport code treats encrypted envelopes and public prekey bundles as bounded opaque bytes. The relay never imports the E2EE implementation or decrypts traffic. +Remote transport uses pairwise application-level E2EE in addition to TLS. The approved direction is PQXDH for asynchronous session establishment and Triple Ratchet for ongoing messages. This direction supersedes any earlier Noise selection. Production cryptography remains blocked on Person 1's security RFC, exact suite, reviewed library, secure-state design, interoperability fixtures, and independent security review. Transport code treats encrypted envelopes and public prekey bundles as bounded opaque bytes. The relay never imports the E2EE implementation or decrypts traffic. The proposed remote action-binding and approval rules are in [`docs/architecture/remote-permission-authorization.md`](docs/architecture/remote-permission-authorization.md); that draft does not enable remote approval. The managed path uses two separately deployable services: the TypeScript control plane owns hosted state and one-use admission, while the Elixir/OTP relay owns bounded in-memory WebSocket routing. The daemon remains the command and session authority. Transport proof uses only disposable sessions, a deterministic fake provider, opaque fixtures, and a test-only fake E2EE adapter. Ordinary-session steering and remote permission approval remain disabled until the E2EE and release gates pass. @@ -2363,6 +2363,7 @@ The integration base for this private slice is clean `main` commit `ea906d0295ba - [x] Add the separately deployable Elixir/OTP relay under `services/relay/` with authenticated admission, opaque bounded framing, in-memory installation-scoped routing, backpressure, heartbeat, lease, revocation, and draining behavior. - [x] Publish language-neutral admission, revocation, and exact binary accept/reject fixtures consumed by both implementations. - [x] Run TypeScript and Mix formatting, compilation, tests, static analysis, dependency auditing, package-boundary, and SPDX/REUSE checks in CI. +- [x] Draft the daemon-owned remote permission action-binding contract without enabling it. - [x] Stop at the architecture checkpoint before daemon, SDK, prekey, attachment, or production integration work. #### Mobile clients diff --git a/docs/architecture/remote-permission-authorization.md b/docs/architecture/remote-permission-authorization.md new file mode 100644 index 00000000..be6a21a3 --- /dev/null +++ b/docs/architecture/remote-permission-authorization.md @@ -0,0 +1,222 @@ + + + +# Remote permission authorization contract + +Status: proposed for architecture and security review + +## Purpose + +This contract binds a remote permission response to one pending daemon action. It defines authorization and durable acceptance after endpoint authentication. It does not define E2EE, pairing, signatures, or ratchet behavior. + +The existing `permission.requested` event does not carry enough action-binding data, and the existing `session.interaction.respond` RPC covers MCP interactions rather than daemon policy approval. Neither existing surface is remotely approvable under this contract. + +## Initial release boundary + +The first remotely approvable action is a gated tool call in an ordinary session that: + +- runs under an enforced sandbox +- remains within the daemon's current policy ceiling +- is already pending local permission review +- exposes `allow_once` and `deny` only +- comes from a device with the effective `approve_within_policy` scope + +Remote `allow_session` is excluded initially because it changes authority for future actions. Unsafe sessions, sandbox bypasses, credential grants, device administration, policy changes, network or filesystem widening, audit changes, and generated-code activation are never remotely approvable. + +Observer devices cannot respond, including with a denial. This prevents an observer from cancelling work. + +## Identifiers + +Use distinct nominal types: + +```ts +type PermissionInteractionId = EventId; +type PolicyGeneration = string; // lowercase RFC 9562 UUID +type ActionDigest = string; // 64 lowercase hexadecimal SHA-256 characters +type DeviceGrantGeneration = number; +``` + +`PermissionInteractionId` is the canonical `permission.action_requested` event ID. It is never reused. No identifier grants authority. + +`PolicyGeneration` is an opaque equality token created and durably stored by the daemon. It changes whenever any input to the effective action policy changes, including permission profile, sandbox enforcement, filesystem or network policy, credential policy, project policy, or administrator ceiling. It is not a counter supplied by a client. + +Hosted and local device-grant generations remain separate from `PolicyGeneration`. The daemon checks all three at acceptance time. + +## Canonical action binding + +The daemon constructs this record only after typed tool input, paths, destinations, and policy effects have been normalized: + +```ts +interface PermissionActionBindingV1 { + readonly version: 1; + readonly sessionId: SessionId; + readonly operationId: OperationId; + readonly interactionId: PermissionInteractionId; + readonly capability: string; + readonly subject: { + readonly kind: "tool_call"; + readonly toolCallEventId: EventId; + readonly callId: string; + readonly toolName: string; + readonly canonicalInputHash: string; + }; + readonly effects: readonly PermissionEffect[]; + readonly policyGeneration: PolicyGeneration; + readonly sandbox: { + readonly securityMode: "sandboxed"; + readonly provider: string; + readonly policyHash: string; + }; + readonly allowedDecisions: readonly ["allow_once", "deny"]; + readonly expiresAt: number; +} +``` + +A `PermissionEffect` is a typed, normalized consequence. Initial variants are: + +```ts +type PermissionEffect = + | { readonly kind: "filesystem_read"; readonly canonicalPath: string } + | { readonly kind: "filesystem_write"; readonly canonicalPath: string } + | { readonly kind: "network_connect"; readonly scheme: string; readonly host: string; readonly port: number } + | { readonly kind: "process_execute"; readonly executable: string } + | { readonly kind: "capability_use"; readonly capability: string }; +``` + +Paths are canonicalized before this record is created. Network hosts use the daemon's canonical host representation. Effects are sorted by the dependency-free canonical JSON encoder's defined order. Duplicate effects are removed. Unknown effect kinds are rejected rather than converted to text. + +`canonicalInputHash` is the existing lowercase SHA-256 hash of the fully validated canonical tool input. Raw arguments remain in their existing canonical tool-call event and are not duplicated into the permission event. + +`policyHash` is the lowercase SHA-256 hash of the normalized effective policy record used for this decision. That record contains rules and credential identifiers, never credential values. The hash is audit binding, not authority. The current policy object remains authoritative. + +## Action digest + +`actionDigest` is lowercase hexadecimal SHA-256 over the exact dependency-free canonical UTF-8 encoding of: + +```text +{ type: "axl.permission-action", binding: PermissionActionBindingV1 } +``` + +The digest excludes transport IDs, device IDs, timestamps, descriptions, UI labels, and the digest itself. It is computed once by the daemon and stored with the canonical request event. + +The digest detects accidental or malicious substitution after endpoint authentication. It is not a signature, possession proof, or replacement for E2EE. + +`expiresAt` is the daemon-created absolute expiry for this interaction. Expiry never extends because a client reconnects or retries. + +Any change to the action, effects, sandbox, policy, or expiry produces a new interaction and digest. The old interaction becomes stale. A digest algorithm or encoding change requires a new binding version. + +## Canonical events + +Add new variants instead of changing historical permission-event meanings. + +```ts +interface PermissionActionRequestedPayload { + readonly binding: PermissionActionBindingV1; + readonly actionDigest: ActionDigest; + readonly description: string; +} + +interface PermissionActionResolvedPayload { + readonly interactionId: PermissionInteractionId; + readonly actionDigest: ActionDigest; + readonly policyGeneration: PolicyGeneration; + readonly decision: "allow_once" | "deny"; + readonly actor: + | { readonly kind: "local_attachment"; readonly attachmentId: string } + | { readonly kind: "remote_device"; readonly deviceId: DeviceId }; +} +``` + +The event types are `permission.action_requested` and `permission.action_resolved`. The request event is appended and synced before any client may answer. The resolved event is the single canonical winner and is appended before execution proceeds. + +Descriptions are presentation text and never participate in the digest. Events contain no credentials, relay tickets, E2EE material, or internal service credentials. + +## Remote response RPC + +Add a daemon RPC named `session.permission.respond`: + +```ts +interface RemotePermissionResponseV1 { + readonly version: 1; + readonly sessionId: SessionId; + readonly interactionId: PermissionInteractionId; + readonly actionDigest: ActionDigest; + readonly policyGeneration: PolicyGeneration; + readonly decision: "allow_once" | "deny"; +} +``` + +The ordinary RPC request ID and UUID idempotency key remain transport metadata. Both are required for a remote mutation. The response does not contain `deviceId`; the daemon uses only the identity established by successful endpoint authentication. + +A local client may use the same RPC with attachment authority. The daemon records the actual actor after authorization. + +## Authorization and acceptance order + +The daemon performs these steps in order: + +1. Bound and parse the outer frame. +2. Authenticate and open it through the injected E2EE boundary. +3. Establish the authenticated device identity. +4. Validate the plaintext RPC schema. +5. Load current hosted and local grants and their revocation generations. +6. Require the effective `approve_within_policy` scope. +7. Require an enforced sandbox and reject unsafe mode or bypass actions. +8. Load the exact pending interaction and reject it after its fixed expiry. +9. Compare session, interaction ID, action digest, and policy generation exactly. +10. Recompute the current policy ceiling and confirm `allow_once` remains an offered decision. +11. Apply the command journal's idempotency and request-hash rules. +12. Atomically accept the first unresolved response. +13. Append and sync `permission.action_resolved` with the authenticated actor. +14. Continue or deny the daemon-owned operation. +15. Seal the response through the endpoint E2EE boundary. + +Decryption establishes identity only. Steps 5 through 13 establish authority and durable acceptance. + +## Races and recovery + +- The first durably accepted response wins across local and remote clients. +- A retry with the same idempotency key and request hash returns the original result. +- Reusing the key for another response returns `idempotency_conflict`. +- A different key after resolution returns `permission_already_resolved` and the canonical resolution event ID. +- A changed policy generation returns `stale_policy` without resolving the interaction. +- A changed digest returns `action_mismatch` without revealing the current action. +- A revoked or narrowed device returns `unauthorized` and creates no acceptance record. +- If acceptance is synced but the resolution event is missing after a crash, restart reconciliation either appends the deterministic resolution or proves no action resumed. It never asks the user to guess. +- Revocation after durable acceptance does not cancel the already daemon-owned operation. A separately authorized interrupt is required. + +## Stable rejection classes + +```text +unknown_permission +permission_expired +permission_already_resolved +stale_policy +action_mismatch +decision_not_allowed +observer_forbidden +device_revoked +scope_forbidden +unsafe_remote_approval_forbidden +sandbox_bypass_forbidden +idempotency_conflict +``` + +Public errors remain bounded and do not echo tool input, paths, commands, policy records, credentials, or device secrets. + +## Required tests before implementation can ship + +- modified action digest, policy generation, session, or interaction fails +- observer, revoked device, narrowed hosted grant, and narrowed local grant fail +- unsafe sessions and sandbox bypasses fail +- remotely supplied device identity is impossible +- `allow_session` is rejected remotely +- simultaneous local and remote responses produce one canonical winner +- same-key retry replays and conflicting-key reuse fails +- restart between acceptance and resolution reconciles without executing twice +- policy changes invalidate every old response +- permission events and diagnostics contain no credentials or cryptographic material +- successful approval cannot exceed the current daemon policy ceiling + +## Release gate + +This draft does not enable remote approvals. Implementation starts only after protocol and security review. User release still requires Person 1's E2EE library, secure state storage, integrated revocation tests, lost-device tests, and independent security review. From 81937e9d5600f20b3f82b6a815c8efc1a6d21a5f Mon Sep 17 00:00:00 2001 From: Lokesh Date: Sat, 12 Sep 2026 21:13:07 +0400 Subject: [PATCH 07/16] docs(remote): record rebased integration commit Signed-off-by: Lokesh --- ROADMAP.md | 2 +- docs/architecture/e2ee-transport-preflight.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3798e7e6..24d2d4c1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2354,7 +2354,7 @@ The shared remote-connectivity and remote-web subsections are a scoped sequencin The transport-first remote-control slice is an approved exception to phase ordering. It may establish service boundaries, opaque framing, one-use ticket admission, bounded relay routing, daemon authorization behind a test-only fake E2EE adapter, and reusable SDK delivery machinery. It must not implement cryptography, select production identity or storage infrastructure, enable ordinary-session remote access, or advertise production remote control. -The integration base for this private slice is clean `main` commit `ea906d0295ba67f833c49ace408a9573551ea687` on `feature/e2ee-transport`. Stop for architecture review after the documentation, separate service boundaries, versioned fixture contract, atomic ticket-consumption path, and first bounded relay slice land. +The private slice was created from clean `main` commit `ea906d0295ba67f833c49ace408a9573551ea687` and rebased for integration onto clean `main` commit `57bd31b7e718a125fc51a0fcf3a554cb100ea708` on `feature/e2ee-transport`. Stop for architecture review after the documentation, separate service boundaries, versioned fixture contract, atomic ticket-consumption path, and first bounded relay slice land. #### Remote transport preflight diff --git a/docs/architecture/e2ee-transport-preflight.md b/docs/architecture/e2ee-transport-preflight.md index d96fda8d..1e60fbd5 100644 --- a/docs/architecture/e2ee-transport-preflight.md +++ b/docs/architecture/e2ee-transport-preflight.md @@ -7,7 +7,7 @@ Status: architecture review checkpoint ## Integration base -The private implementation branch is `feature/e2ee-transport`, created from clean `main` commit `ea906d0295ba67f833c49ace408a9573551ea687`. +The private implementation branch is `feature/e2ee-transport`. It was created from clean `main` commit `ea906d0295ba67f833c49ace408a9573551ea687` and rebased for integration onto clean `main` commit `57bd31b7e718a125fc51a0fcf3a554cb100ea708`. ## Scope From 9791c8c22fb3d0832f510f572d2d250ad9299d8c Mon Sep 17 00:00:00 2001 From: Lokesh Date: Sun, 13 Sep 2026 12:07:04 +0400 Subject: [PATCH 08/16] fix(relay): resolve transport review findings Signed-off-by: Lokesh --- docs/architecture/e2ee-transport-preflight.md | 22 +- packages/protocol/src/remote-transport.ts | 82 +++++- .../test/fixtures/internal-relay-api-v1.json | 18 ++ .../protocol/test/remote-transport.test.ts | 21 ++ services/control-plane/src/tickets.ts | 24 +- services/control-plane/test/tickets.test.ts | 30 ++- services/relay/README.md | 7 +- services/relay/lib/axl_relay/connection.ex | 59 ++++- services/relay/lib/axl_relay/frame.ex | 3 +- .../axl_relay/http_control_plane_client.ex | 6 +- .../relay/lib/axl_relay/route_registry.ex | 243 ++++++++++++------ services/relay/test/frame_test.exs | 3 +- .../relay/test/internal_contract_test.exs | 10 + services/relay/test/route_registry_test.exs | 98 ++++++- services/relay/test/websocket_relay_test.exs | 76 +++++- 15 files changed, 610 insertions(+), 92 deletions(-) diff --git a/docs/architecture/e2ee-transport-preflight.md b/docs/architecture/e2ee-transport-preflight.md index 1e60fbd5..37c33ff8 100644 --- a/docs/architecture/e2ee-transport-preflight.md +++ b/docs/architecture/e2ee-transport-preflight.md @@ -69,6 +69,10 @@ Clients connect to `/v1/connect` with compression disabled. They do not put a ti The relay adds its own instance ID and calls the control plane. Proof bytes and proof verification are fake and test-only in this checkpoint. No production proof construction is implied. +After admission, the relay sends a `route_snapshot` control message with the connection's ephemeral source route and only opposite-role peers from the same installation. A device sees at most the current daemon route. The daemon sees authorized device routes and their opaque device IDs. `route_available` and `route_unavailable` messages update this view after reconnects. Devices never enumerate other devices. + +One daemon route is active per installation and one route is active per device ID. A newer authenticated connection replaces the older same-identity route. Routing permits only `device -> daemon` and `daemon -> device`; same-role and cross-installation delivery returns `forbidden_route`. + ## Binary relay framing `packages/protocol/test/fixtures/remote-transport-v1.json` is the byte-level cross-language fixture. Every integer is unsigned big-endian. UUIDs use their 16 RFC 9562 bytes. @@ -98,7 +102,7 @@ Receipt and failure frames instead contain one byte at offset 22. Receipt values 3 unauthorized 9 queue_full 4 forbidden_route 10 slow_consumer 5 ticket_expired 11 service_unavailable -6 ticket_consumed +6 ticket_consumed 12 ticket_revoked ``` These assignments must not be reordered. A new failure receives a new number or requires a transport-version change. @@ -119,6 +123,18 @@ A client may remove a mutation from its durable outbox only after `daemon_accept The first relay slice uses pinned Bandit, Plug, and WebSock Adapter production dependencies. They are approved for this boundary. Cowboy was evaluated and rejected after its locked version reported active security advisories. Credo, Dialyxir, and mix_audit are development-only checks. +## Heartbeats and half-open connections + +The relay sends a ping every 20 seconds and records inbound activity with a monotonic clock. A valid binary frame, ping, or pong updates liveness. Outbound pings do not. A connection closes with `idle_timeout` after 60 seconds without valid inbound activity. Ticket lease expiry is an independent hard deadline and is never extended by heartbeat traffic. + +## Slow consumers + +Each route has a 512 KiB application queue ceiling. Reaching the ceiling starts a 10-second saturation timer and further enqueue attempts fail with `queue_full`. If queued bytes do not fall to 256 KiB or less before the timer fires, the relay evicts the destination with `slow_consumer`. Bytes remain charged until the WebSocket adapter accepts the push. `forwarded` still does not prove endpoint or network receipt. Deployment must separately bound kernel socket buffers, and load tests must measure them. + +## Revocation races + +Every ticket stores the hosted grant generation observed at issuance. Atomic consumption rechecks the current generation and rejects a missing or changed grant with `ticket_revoked`. The admission result carries that generation. Relay revocation notifications close routes admitted at or before the revoked generation and prevent their stale re-registration. A missed relay notification still cannot authorize a daemon command because the daemon rechecks current grants before durable acceptance. + ## Reviewed limits ```text @@ -129,6 +145,10 @@ idle timeout: 60 seconds maximum ticket lifetime: 60 seconds ``` +## Review resolutions + +The architecture review selected role-filtered relay discovery, strict opposite-role topology, monotonic inbound-idle tracking, timed slow-consumer eviction, and grant-generation-bound ticket consumption. The implementation and cross-language fixtures now enforce those decisions. Socket-adapter acceptance remains distinct from network or endpoint receipt, and production socket-memory bounds remain a deployment and load-test requirement. + ## Review boundary Stop here after the documentation, CI boundaries, fixtures, ticket-consumption path, and first bounded relay slice pass. Daemon authorization, SDK outbox behavior, prekey storage, S3 transport, real E2EE integration, ordinary-session steering, and permission approvals require the next reviewed milestone. diff --git a/packages/protocol/src/remote-transport.ts b/packages/protocol/src/remote-transport.ts index 37631054..4090c282 100644 --- a/packages/protocol/src/remote-transport.ts +++ b/packages/protocol/src/remote-transport.ts @@ -83,6 +83,7 @@ export interface ConsumeRelayTicketResult { readonly deviceId?: DeviceId; readonly sourceRouteId: RouteId; readonly role: "daemon" | "device"; + readonly grantGeneration: number; readonly leaseExpiresAt: number; readonly limits: RelayLimits; } @@ -121,6 +122,7 @@ export const RELAY_FAILURE_CODE_VALUES = Object.freeze({ queue_full: 9, slow_consumer: 10, service_unavailable: 11, + ticket_revoked: 12, } as const); export type RelayFailureCode = keyof typeof RELAY_FAILURE_CODE_VALUES; @@ -163,6 +165,19 @@ export interface OpaqueOutboxRecord { readonly state: "queued_local" | "sending" | "daemon_accepted"; } +export interface RelayPeerRoute { + readonly routeId: RouteId; + readonly role: "daemon" | "device"; + readonly deviceId?: DeviceId; +} + +export interface RelayDiscoveryMessage { + readonly version: typeof REMOTE_TRANSPORT_VERSION; + readonly type: "route_snapshot" | "route_available" | "route_unavailable"; + readonly sourceRoute?: RelayPeerRoute; + readonly peers: readonly RelayPeerRoute[]; +} + export interface RelayRevocationNotification { readonly version: typeof INTERNAL_RELAY_API_VERSION; readonly installationId: InstallationId; @@ -444,7 +459,15 @@ export function parseInternalConsumeRelayTicketResult(value: unknown): ConsumeRe exact( candidate, "result", - ["version", "installationId", "sourceRouteId", "role", "leaseExpiresAt", "limits"], + [ + "version", + "installationId", + "sourceRouteId", + "role", + "grantGeneration", + "leaseExpiresAt", + "limits", + ], ["deviceId"], ); if (candidate.version !== INTERNAL_RELAY_API_VERSION) { @@ -462,6 +485,12 @@ export function parseInternalConsumeRelayTicketResult(value: unknown): ConsumeRe ...(deviceId === undefined ? {} : { deviceId }), sourceRouteId: parseRouteId(candidate.sourceRouteId, "result.sourceRouteId"), role: parsedRole, + grantGeneration: integer( + candidate.grantGeneration, + "result.grantGeneration", + 1, + Number.MAX_SAFE_INTEGER, + ), leaseExpiresAt: timestamp(candidate.leaseExpiresAt, "result.leaseExpiresAt"), limits: parseRelayLimits(candidate.limits, "result.limits"), }; @@ -473,6 +502,57 @@ export function encodeInternalConsumeRelayTicketResult( return { version: INTERNAL_RELAY_API_VERSION, ...result }; } +function parseRelayPeerRoute(value: unknown, path: string): RelayPeerRoute { + const candidate = object(value, path); + exact(candidate, path, ["routeId", "role"], ["deviceId"]); + const parsedRole = role(candidate.role, `${path}.role`); + const deviceId = + candidate.deviceId === undefined + ? undefined + : parseDeviceId(candidate.deviceId, `${path}.deviceId`); + if (parsedRole === "device" && deviceId === undefined) fail(`${path}.deviceId`, "is required"); + if (parsedRole === "daemon" && deviceId !== undefined) fail(`${path}.deviceId`, "is not allowed"); + return { + routeId: parseRouteId(candidate.routeId, `${path}.routeId`), + role: parsedRole, + ...(deviceId === undefined ? {} : { deviceId }), + }; +} + +export function parseRelayDiscoveryMessage(value: unknown): RelayDiscoveryMessage { + const candidate = object(value, "discovery"); + exact(candidate, "discovery", ["version", "type", "peers"], ["sourceRoute"]); + if (candidate.version !== REMOTE_TRANSPORT_VERSION) { + fail("discovery.version", `must equal ${REMOTE_TRANSPORT_VERSION}`); + } + if ( + candidate.type !== "route_snapshot" && + candidate.type !== "route_available" && + candidate.type !== "route_unavailable" + ) { + fail("discovery.type", "is invalid"); + } + if (!Array.isArray(candidate.peers) || candidate.peers.length > 256) { + fail("discovery.peers", "must be an array of at most 256 routes"); + } + if (candidate.type === "route_snapshot" && candidate.sourceRoute === undefined) { + fail("discovery.sourceRoute", "is required for a snapshot"); + } + if (candidate.type !== "route_snapshot" && candidate.sourceRoute !== undefined) { + fail("discovery.sourceRoute", "is allowed only for a snapshot"); + } + return { + version: REMOTE_TRANSPORT_VERSION, + type: candidate.type, + ...(candidate.sourceRoute === undefined + ? {} + : { sourceRoute: parseRelayPeerRoute(candidate.sourceRoute, "discovery.sourceRoute") }), + peers: candidate.peers.map((peer, index) => + parseRelayPeerRoute(peer, `discovery.peers[${index}]`), + ), + }; +} + export function parseRelayRevocationNotification(value: unknown): RelayRevocationNotification { const candidate = object(value, "request"); exact( diff --git a/packages/protocol/test/fixtures/internal-relay-api-v1.json b/packages/protocol/test/fixtures/internal-relay-api-v1.json index 7a6596de..9a1622dd 100644 --- a/packages/protocol/test/fixtures/internal-relay-api-v1.json +++ b/packages/protocol/test/fixtures/internal-relay-api-v1.json @@ -14,6 +14,7 @@ "deviceId": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "sourceRouteId": "cccccccc-cccc-4ccc-8ccc-cccccccccccc", "role": "device", + "grantGeneration": 7, "leaseExpiresAt": 2000000000000, "limits": { "maxFrameBytes": 65535, @@ -23,6 +24,23 @@ } } }, + "discovery": { + "deviceSnapshot": { + "version": 1, + "type": "route_snapshot", + "sourceRoute": { + "routeId": "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + "role": "device", + "deviceId": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + }, + "peers": [ + { + "routeId": "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + "role": "daemon" + } + ] + } + }, "revocation": { "request": { "version": 1, diff --git a/packages/protocol/test/remote-transport.test.ts b/packages/protocol/test/remote-transport.test.ts index be0c0e89..51548afe 100644 --- a/packages/protocol/test/remote-transport.test.ts +++ b/packages/protocol/test/remote-transport.test.ts @@ -18,6 +18,7 @@ import { parseInternalConsumeRelayTicketResult, parseIssueRelayTicketRequest, parseRelayBinaryFrame, + parseRelayDiscoveryMessage, parseRelayRevocationNotification, ProtocolValidationError, RELAY_FAILURE_CODE_VALUES, @@ -46,6 +47,7 @@ const internalFixtures = JSON.parse( readFileSync(new URL("./fixtures/internal-relay-api-v1.json", import.meta.url), "utf8"), ) as { readonly consumeTicket: { readonly request: unknown; readonly result: unknown }; + readonly discovery: { readonly deviceSnapshot: unknown }; readonly revocation: { readonly request: unknown; readonly result: unknown }; }; @@ -104,6 +106,7 @@ test("keeps relay failure byte assignments stable", () => { queue_full: 9, slow_consumer: 10, service_unavailable: 11, + ticket_revoked: 12, }); }); @@ -122,6 +125,23 @@ test("enforces the complete frame bound before encoding", () => { ); }); +test("validates role-scoped route discovery messages", () => { + assert.deepEqual( + parseRelayDiscoveryMessage(internalFixtures.discovery.deviceSnapshot), + internalFixtures.discovery.deviceSnapshot, + ); + assert.throws( + () => + parseRelayDiscoveryMessage({ + version: 1, + type: "route_available", + sourceRoute: { routeId: "11111111-1111-4111-8111-111111111111", role: "daemon" }, + peers: [], + }), + (error) => error instanceof ProtocolValidationError && error.path === "discovery.sourceRoute", + ); +}); + test("keeps the deterministic fake E2EE adapter in test support", async () => { const daemonId = parseDeviceId("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); const deviceId = parseDeviceId("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); @@ -174,6 +194,7 @@ test("validates ticket roles and the language-neutral internal contract", () => deviceId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", sourceRouteId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", role: "device", + grantGeneration: 7, leaseExpiresAt: 2_000_000_000_000, limits: DEFAULT_RELAY_LIMITS, }); diff --git a/services/control-plane/src/tickets.ts b/services/control-plane/src/tickets.ts index bb8fc0cf..873a7ab6 100644 --- a/services/control-plane/src/tickets.ts +++ b/services/control-plane/src/tickets.ts @@ -26,7 +26,10 @@ export interface Clock { } export interface RelayTicketAuthorizer { - authorize(principal: AccountPrincipal, request: IssueRelayTicketRequest): Promise; + currentGeneration( + principal: AccountPrincipal, + request: IssueRelayTicketRequest, + ): Promise; } export interface RelayTicketProofVerifier { @@ -34,6 +37,8 @@ export interface RelayTicketProofVerifier { } export interface RelayTicketRecord extends IssueRelayTicketRequest { + readonly accountId: string; + readonly grantGeneration: number; readonly ticketDigest: string; readonly sourceRouteId: ConsumeRelayTicketResult["sourceRouteId"]; readonly issuedAt: number; @@ -59,6 +64,7 @@ export type RelayTicketErrorCode = | "forbidden_route" | "ticket_expired" | "ticket_consumed" + | "ticket_revoked" | "service_unavailable"; export class RelayTicketError extends Error { @@ -148,13 +154,19 @@ export class RelayTicketService { async issue(principal: AccountPrincipal, value: unknown): Promise { const request = parseIssueRelayTicketRequest(value); - if (!(await this.options.authorizer.authorize(principal, request))) { + const grantGeneration = await this.options.authorizer.currentGeneration(principal, request); + if (grantGeneration === undefined) { throw new RelayTicketError("forbidden_route", "Principal cannot access this route", 403); } + if (!Number.isSafeInteger(grantGeneration) || grantGeneration <= 0) { + throw new Error("Grant generation must be a positive safe integer"); + } const now = this.clock.now(); const ticket = this.randomToken(); const record: RelayTicketRecord = { ...request, + accountId: principal.accountId, + grantGeneration, ticketDigest: digestTicket(ticket), sourceRouteId: parseRouteId(this.randomId(), "sourceRouteId"), issuedAt: now, @@ -180,6 +192,13 @@ export class RelayTicketService { if (!(await this.options.proofVerifier.verify(candidate, request))) { throw new RelayTicketError("unauthorized", "Possession proof is invalid", 401); } + const currentGeneration = await this.options.authorizer.currentGeneration( + { accountId: candidate.accountId }, + candidate, + ); + if (currentGeneration === undefined || currentGeneration !== candidate.grantGeneration) { + throw new RelayTicketError("ticket_revoked", "Relay ticket grant is no longer current", 401); + } const consumed = await this.options.store.consume( ticketDigest, request.relayInstanceId, @@ -190,6 +209,7 @@ export class RelayTicketService { ...(consumed.deviceId === undefined ? {} : { deviceId: consumed.deviceId }), sourceRouteId: consumed.sourceRouteId, role: consumed.role, + grantGeneration: consumed.grantGeneration, leaseExpiresAt: consumed.expiresAt, limits: consumed.limits, }; diff --git a/services/control-plane/test/tickets.test.ts b/services/control-plane/test/tickets.test.ts index 33d73eba..177b302c 100644 --- a/services/control-plane/test/tickets.test.ts +++ b/services/control-plane/test/tickets.test.ts @@ -36,15 +36,17 @@ const fixture = JSON.parse( function createTicketService( clock: { now(): number } = { now: () => 1_900_000_000_000 }, + currentGeneration: () => number | undefined = () => 1, ): RelayTicketService { let routeCounter = 0; return new RelayTicketService({ store: new InMemoryRelayTicketStore(), authorizer: { - async authorize(principal, request) { - return ( - principal.accountId === "account-fixture" && request.installationId === installationId - ); + async currentGeneration(principal, request) { + return principal.accountId === "account-fixture" && + request.installationId === installationId + ? currentGeneration() + : undefined; }, }, proofVerifier: { @@ -121,6 +123,25 @@ test("rejects unauthorized issuance, invalid proof, and expired tickets", async ); }); +test("rejects a ticket when its grant generation changes before consumption", async () => { + let generation: number | undefined = 7; + const service = createTicketService(undefined, () => generation); + const issued = await service.issue( + { accountId: "account-fixture" }, + { installationId, deviceId, role: "device" }, + ); + generation = 8; + await assert.rejects( + service.consume({ + ticket: issued.ticket, + relayInstanceId: "relay-fixture-1", + connectionNonce: "fixture-nonce", + possessionProof: Uint8Array.of(0, 1, 2, 3, 255), + }), + (error) => error instanceof RelayTicketError && error.code === "ticket_revoked", + ); +}); + test("serves authenticated public issuance and internal consumption without URL credentials", async (context) => { const service = createTicketService(); const handler = createControlPlaneHandler({ @@ -187,6 +208,7 @@ test("serves authenticated public issuance and internal consumption without URL deviceId, sourceRouteId: "cccccccc-cccc-4ccc-8ccc-000000000001", role: "device", + grantGeneration: 1, leaseExpiresAt: 1_900_000_060_000, limits: DEFAULT_RELAY_LIMITS, }); diff --git a/services/relay/README.md b/services/relay/README.md index f7a9aa54..4921482a 100644 --- a/services/relay/README.md +++ b/services/relay/README.md @@ -9,10 +9,11 @@ The first slice provides: - one-use ticket admission through an injected control-plane client - exact transport-v1 binary framing shared with TypeScript fixtures -- installation-scoped in-memory route registration -- bounded per-route pending bytes +- role-filtered route snapshots and updates without device-to-device enumeration +- installation-scoped `device <-> daemon` routing with same-identity replacement +- bounded per-route pending bytes and timed slow-consumer eviction - WebSocket compression disabled and a 65,535-byte frame ceiling -- heartbeat, idle, lease-expiry, revocation, and draining behavior +- explicit inbound heartbeat deadlines, lease expiry, generation-bound revocation, and draining - fail-closed admission and internal-authentication interfaces Production control-plane origins, service authentication, TLS termination, and deployment configuration remain unselected. Tests use deterministic fake adapters. diff --git a/services/relay/lib/axl_relay/connection.ex b/services/relay/lib/axl_relay/connection.ex index 6033e1f3..7d2a88c5 100644 --- a/services/relay/lib/axl_relay/connection.ex +++ b/services/relay/lib/axl_relay/connection.ex @@ -26,6 +26,7 @@ defmodule AxlRelay.Connection do route_id: nil, limits: nil, rate_window_started: System.monotonic_time(:millisecond), + last_inbound_at: nil, rate_frames: 0, rate_bytes: 0 }} @@ -50,7 +51,14 @@ defmodule AxlRelay.Connection do result.lease_expires_at - System.system_time(:millisecond) ) - {:ok, %{state | phase: :active, route_id: result.source_route_id, limits: result.limits}} + {:ok, + %{ + state + | phase: :active, + route_id: result.source_route_id, + limits: result.limits, + last_inbound_at: System.monotonic_time(:millisecond) + }} else {:error, code} -> close(code, state) false -> close(:ticket_expired, state) @@ -62,6 +70,7 @@ defmodule AxlRelay.Connection do with true <- byte_size(message) <= state.limits.max_frame_bytes, {:ok, %{kind: :send} = frame} <- Frame.decode(message), {:ok, rate_state} <- rate_limit(state, byte_size(message)) do + rate_state = %{rate_state | last_inbound_at: System.monotonic_time(:millisecond)} admitted = receipt(frame.attempt_id, :admitted) case RouteRegistry.forward( @@ -86,6 +95,10 @@ defmodule AxlRelay.Connection do def handle_in(_frame, state), do: close(:bad_frame, state) @impl true + def handle_control({_payload, opcode: opcode}, %{phase: :active} = state) + when opcode in [:ping, :pong], + do: {:ok, %{state | last_inbound_at: System.monotonic_time(:millisecond)}} + def handle_control({_payload, opcode: opcode}, state) when opcode in [:ping, :pong], do: {:ok, state} @@ -112,12 +125,32 @@ defmodule AxlRelay.Connection do end def handle_info(:heartbeat, %{phase: :active} = state) do - Process.send_after(self(), :heartbeat, state.limits.heartbeat_interval_ms) - {:push, {:ping, <<>>}, state} + now = System.monotonic_time(:millisecond) + + if now - state.last_inbound_at >= state.limits.idle_timeout_ms do + close(:idle_timeout, state) + else + Process.send_after(self(), :heartbeat, state.limits.heartbeat_interval_ms) + {:push, {:ping, <<>>}, state} + end + end + + def handle_info({:route_snapshot, own, peers}, state) do + {:push, {:binary, discovery("route_snapshot", own, peers)}, state} + end + + def handle_info({:route_available, peer}, state) do + {:push, {:binary, discovery("route_available", nil, [peer])}, state} + end + + def handle_info({:route_unavailable, peer}, state) do + {:push, {:binary, discovery("route_unavailable", nil, [peer])}, state} end def handle_info(:lease_expired, state), do: close(:unauthorized, state) def handle_info(:route_revoked, state), do: close(:unauthorized, state) + def handle_info(:route_replaced, state), do: close(:unauthorized, state) + def handle_info(:slow_consumer, state), do: close(:slow_consumer, state) def handle_info(:relay_draining, state), do: close(:service_unavailable, state) def handle_info(:admission_timeout, %{phase: :awaiting_admission} = state), @@ -171,6 +204,26 @@ defmodule AxlRelay.Connection do encoded end + defp discovery(type, own, peers) do + message = %{ + "version" => 1, + "type" => type, + "peers" => Enum.map(peers, &json_route/1) + } + + message = if own == nil, do: message, else: Map.put(message, "sourceRoute", json_route(own)) + message |> :json.encode() |> IO.iodata_to_binary() + end + + defp json_route(route) do + value = %{ + "routeId" => route.route_id, + "role" => Atom.to_string(route.role) + } + + if route.device_id == nil, do: value, else: Map.put(value, "deviceId", route.device_id) + end + defp close(code, state), do: {:stop, :normal, {1008, Atom.to_string(code)}, state} end diff --git a/services/relay/lib/axl_relay/frame.ex b/services/relay/lib/axl_relay/frame.ex index bd533099..e1905009 100644 --- a/services/relay/lib/axl_relay/frame.ex +++ b/services/relay/lib/axl_relay/frame.ex @@ -20,7 +20,8 @@ defmodule AxlRelay.Frame do 8 => :rate_limited, 9 => :queue_full, 10 => :slow_consumer, - 11 => :service_unavailable + 11 => :service_unavailable, + 12 => :ticket_revoked } @type relay_frame :: diff --git a/services/relay/lib/axl_relay/http_control_plane_client.ex b/services/relay/lib/axl_relay/http_control_plane_client.ex index 08cf5446..02eae8c6 100644 --- a/services/relay/lib/axl_relay/http_control_plane_client.ex +++ b/services/relay/lib/axl_relay/http_control_plane_client.ex @@ -74,7 +74,8 @@ defmodule AxlRelay.HttpControlPlaneClient do %{ "unauthorized" => :unauthorized, "ticket_expired" => :ticket_expired, - "ticket_consumed" => :ticket_consumed + "ticket_consumed" => :ticket_consumed, + "ticket_revoked" => :ticket_revoked }, code ) do @@ -92,6 +93,7 @@ defmodule AxlRelay.HttpControlPlaneClient do "installationId", "sourceRouteId", "role", + "grantGeneration", "leaseExpiresAt", "limits" ]) @@ -105,6 +107,7 @@ defmodule AxlRelay.HttpControlPlaneClient do true <- uuid?(result["sourceRouteId"]), role when role in ["daemon", "device"] <- result["role"], true <- valid_device?(role, result["deviceId"]), + generation when is_integer(generation) and generation > 0 <- result["grantGeneration"], lease when is_integer(lease) and lease >= 0 <- result["leaseExpiresAt"], {:ok, limits} <- validate_limits(result["limits"]) do {:ok, @@ -113,6 +116,7 @@ defmodule AxlRelay.HttpControlPlaneClient do device_id: result["deviceId"], source_route_id: result["sourceRouteId"], role: String.to_existing_atom(role), + grant_generation: generation, lease_expires_at: lease, limits: limits }} diff --git a/services/relay/lib/axl_relay/route_registry.ex b/services/relay/lib/axl_relay/route_registry.ex index 0e8017e7..6a528fb6 100644 --- a/services/relay/lib/axl_relay/route_registry.ex +++ b/services/relay/lib/axl_relay/route_registry.ex @@ -2,16 +2,11 @@ # SPDX-License-Identifier: Apache-2.0 defmodule AxlRelay.RouteRegistry do - @moduledoc "In-memory, installation-scoped route table with bounded pending bytes." + @moduledoc "Role-scoped in-memory routes with bounded pending bytes and eviction." use GenServer - @type admission :: %{ - installation_id: String.t(), - device_id: String.t() | nil, - source_route_id: String.t(), - limits: %{max_queued_bytes: pos_integer()} - } + @default_slow_consumer_grace_ms 10_000 def start_link(options \\ []) do case Keyword.get(options, :name, __MODULE__) do @@ -20,68 +15,88 @@ defmodule AxlRelay.RouteRegistry do end end - def register(server \\ __MODULE__, pid, admission) do - GenServer.call(server, {:register, pid, admission}) - end + def register(server \\ __MODULE__, pid, admission), + do: GenServer.call(server, {:register, pid, admission}) - def unregister(server \\ __MODULE__, route_id) do - GenServer.call(server, {:unregister, route_id}) - end + def unregister(server \\ __MODULE__, route_id), + do: GenServer.call(server, {:unregister, route_id}) def forward(server \\ __MODULE__, source_route_id, destination_route_id, attempt_id, payload) do - GenServer.call( - server, - {:forward, source_route_id, destination_route_id, attempt_id, payload} - ) + GenServer.call(server, {:forward, source_route_id, destination_route_id, attempt_id, payload}) end - def delivered(server \\ __MODULE__, route_id, bytes) do - GenServer.cast(server, {:delivered, route_id, bytes}) - end + def delivered(server \\ __MODULE__, route_id, bytes), + do: GenServer.cast(server, {:delivered, route_id, bytes}) - def revoke(server \\ __MODULE__, notification) do - GenServer.call(server, {:revoke, notification}) - end + def revoke(server \\ __MODULE__, notification), + do: GenServer.call(server, {:revoke, notification}) - def drain(server \\ __MODULE__) do - GenServer.call(server, :drain) - end - - def snapshot(server \\ __MODULE__) do - GenServer.call(server, :snapshot) - end + def drain(server \\ __MODULE__), do: GenServer.call(server, :drain) + def snapshot(server \\ __MODULE__), do: GenServer.call(server, :snapshot) @impl true - def init(_options) do - {:ok, %{routes: %{}, monitors: %{}, generations: %{}, draining: false}} + def init(options) do + {:ok, + %{ + routes: %{}, + monitors: %{}, + generations: %{}, + draining: false, + slow_consumer_grace_ms: + Keyword.get(options, :slow_consumer_grace_ms, @default_slow_consumer_grace_ms) + }} end @impl true - def handle_call({:register, _pid, _admission}, _from, %{draining: true} = state) do - {:reply, {:error, :service_unavailable}, state} - end + def handle_call({:register, _pid, _admission}, _from, %{draining: true} = state), + do: {:reply, {:error, :service_unavailable}, state} def handle_call({:register, pid, admission}, _from, state) do route_id = admission.source_route_id - if Map.has_key?(state.routes, route_id) do - {:reply, {:error, :forbidden_route}, state} - else - monitor = Process.monitor(pid) - route = Map.merge(admission, %{pid: pid, monitor: monitor, queued_bytes: 0}) + cond do + Map.has_key?(state.routes, route_id) -> + {:reply, {:error, :forbidden_route}, state} - {:reply, :ok, - %{ - state - | routes: Map.put(state.routes, route_id, route), - monitors: Map.put(state.monitors, monitor, route_id) - }} + admission.grant_generation <= revoked_generation(state, admission) -> + {:reply, {:error, :ticket_revoked}, state} + + true -> + replacements = + Enum.filter(state.routes, fn {_id, route} -> same_identity?(route, admission) end) + + Enum.each(replacements, fn {_id, route} -> send(route.pid, :route_replaced) end) + + without_replaced = + Enum.reduce(replacements, state, fn {id, _route}, current -> + remove_route(current, id, false) + end) + + monitor = Process.monitor(pid) + + route = + Map.merge(admission, %{ + pid: pid, + monitor: monitor, + queued_bytes: 0, + saturation_token: nil + }) + + next = %{ + without_replaced + | routes: Map.put(without_replaced.routes, route_id, route), + monitors: Map.put(without_replaced.monitors, monitor, route_id) + } + + peers = visible_peers(next, route) + send(pid, {:route_snapshot, descriptor(route), Enum.map(peers, &descriptor/1)}) + Enum.each(peers, fn peer -> send(peer.pid, {:route_available, descriptor(route)}) end) + {:reply, :ok, next} end end - def handle_call({:unregister, route_id}, _from, state) do - {:reply, :ok, remove_route(state, route_id)} - end + def handle_call({:unregister, route_id}, _from, state), + do: {:reply, :ok, remove_route(state, route_id)} def handle_call( {:forward, source_route_id, destination_route_id, attempt_id, payload}, @@ -99,11 +114,11 @@ defmodule AxlRelay.RouteRegistry do destination == nil -> {:reply, {:error, :destination_offline}, state} - source.installation_id != destination.installation_id -> + source.installation_id != destination.installation_id or source.role == destination.role -> {:reply, {:error, :forbidden_route}, state} destination.queued_bytes + queued_bytes > destination.limits.max_queued_bytes -> - {:reply, {:error, :queue_full}, state} + {:reply, {:error, :queue_full}, mark_saturated(state, destination_route_id)} true -> send( @@ -111,14 +126,15 @@ defmodule AxlRelay.RouteRegistry do {:relay_delivery, source_route_id, attempt_id, payload, queued_bytes} ) - next_state = - put_in( - state, - [:routes, destination_route_id, :queued_bytes], - destination.queued_bytes + queued_bytes - ) + next_bytes = destination.queued_bytes + queued_bytes + next = put_in(state, [:routes, destination_route_id, :queued_bytes], next_bytes) + + next = + if next_bytes >= destination.limits.max_queued_bytes, + do: mark_saturated(next, destination_route_id), + else: next - {:reply, :ok, next_state} + {:reply, :ok, next} end end @@ -130,21 +146,21 @@ defmodule AxlRelay.RouteRegistry do {:reply, :ok, state} else matching = - state.routes - |> Enum.filter(fn {_route_id, route} -> + Enum.filter(state.routes, fn {_route_id, route} -> route.installation_id == notification.installation_id and - (notification.device_id == nil or route.device_id == notification.device_id) + (notification.device_id == nil or route.device_id == notification.device_id) and + route.grant_generation <= notification.generation end) Enum.each(matching, fn {_route_id, route} -> send(route.pid, :route_revoked) end) - next_state = + next = Enum.reduce(matching, state, fn {route_id, _route}, current -> remove_route(current, route_id) end) {:reply, :ok, - %{next_state | generations: Map.put(next_state.generations, key, notification.generation)}} + %{next | generations: Map.put(next.generations, key, notification.generation)}} end end @@ -160,6 +176,8 @@ defmodule AxlRelay.RouteRegistry do %{ installation_id: route.installation_id, device_id: route.device_id, + role: route.role, + grant_generation: route.grant_generation, queued_bytes: route.queued_bytes }} end) @@ -174,32 +192,111 @@ defmodule AxlRelay.RouteRegistry do {:noreply, state} route -> - next_state = - put_in(state, [:routes, route_id, :queued_bytes], max(0, route.queued_bytes - bytes)) + queued = max(0, route.queued_bytes - bytes) + next = put_in(state, [:routes, route_id, :queued_bytes], queued) - {:noreply, next_state} + next = + if queued <= div(route.limits.max_queued_bytes, 2) do + put_in(next, [:routes, route_id, :saturation_token], nil) + else + next + end + + {:noreply, next} end end @impl true - def handle_info({:DOWN, monitor, :process, _pid, _reason}, state) do - case Map.pop(state.monitors, monitor) do - {nil, _monitors} -> + def handle_info({:slow_consumer_check, route_id, token}, state) do + case state.routes[route_id] do + %{saturation_token: ^token} = route -> + if route.queued_bytes > div(route.limits.max_queued_bytes, 2) do + send(route.pid, :slow_consumer) + {:noreply, remove_route(state, route_id)} + else + {:noreply, put_in(state, [:routes, route_id, :saturation_token], nil)} + end + + _other -> {:noreply, state} + end + end - {route_id, monitors} -> - {:noreply, %{state | routes: Map.delete(state.routes, route_id), monitors: monitors}} + def handle_info({:DOWN, monitor, :process, _pid, _reason}, state) do + case state.monitors[monitor] do + nil -> {:noreply, state} + route_id -> {:noreply, remove_route(state, route_id)} end end - defp remove_route(state, route_id) do + defp mark_saturated(state, route_id) do + case state.routes[route_id] do + nil -> + state + + %{saturation_token: nil} -> + token = make_ref() + + Process.send_after( + self(), + {:slow_consumer_check, route_id, token}, + state.slow_consumer_grace_ms + ) + + put_in(state, [:routes, route_id, :saturation_token], token) + + _route -> + state + end + end + + defp visible_peers(state, route) do + state.routes + |> Map.values() + |> Enum.filter(fn candidate -> + candidate.source_route_id != route.source_route_id and + candidate.installation_id == route.installation_id and candidate.role != route.role + end) + end + + defp descriptor(route) do + %{ + route_id: route.source_route_id, + role: route.role, + device_id: route.device_id + } + end + + defp same_identity?(left, right) do + left.installation_id == right.installation_id and left.role == right.role and + (left.role == :daemon or left.device_id == right.device_id) + end + + defp revoked_generation(state, admission) do + all = Map.get(state.generations, {admission.installation_id, :all}, 0) + device = Map.get(state.generations, {admission.installation_id, admission.device_id}, 0) + max(all, device) + end + + defp remove_route(state, route_id, notify \\ true) do case Map.pop(state.routes, route_id) do {nil, _routes} -> state {route, routes} -> Process.demonitor(route.monitor, [:flush]) - %{state | routes: routes, monitors: Map.delete(state.monitors, route.monitor)} + next = %{state | routes: routes, monitors: Map.delete(state.monitors, route.monitor)} + + notify_unavailable(next, route, notify) + next end end + + defp notify_unavailable(_state, _route, false), do: :ok + + defp notify_unavailable(state, route, true) do + Enum.each(visible_peers(state, route), fn peer -> + send(peer.pid, {:route_unavailable, descriptor(route)}) + end) + end end diff --git a/services/relay/test/frame_test.exs b/services/relay/test/frame_test.exs index 4cf56995..c7aaf953 100644 --- a/services/relay/test/frame_test.exs +++ b/services/relay/test/frame_test.exs @@ -40,7 +40,8 @@ defmodule AxlRelay.FrameTest do rate_limited: 8, queue_full: 9, slow_consumer: 10, - service_unavailable: 11 + service_unavailable: 11, + ticket_revoked: 12 ] for {code, value} <- codes do diff --git a/services/relay/test/internal_contract_test.exs b/services/relay/test/internal_contract_test.exs index 70104d9e..8654fc6c 100644 --- a/services/relay/test/internal_contract_test.exs +++ b/services/relay/test/internal_contract_test.exs @@ -20,10 +20,20 @@ defmodule AxlRelay.InternalContractTest do assert parsed.device_id == result["deviceId"] assert parsed.source_route_id == result["sourceRouteId"] assert parsed.role == :device + assert parsed.grant_generation == result["grantGeneration"] assert parsed.limits.max_frame_bytes == 65_535 assert parsed.limits.max_queued_bytes == 524_288 end + test "accepts the role-filtered discovery fixture" do + snapshot = @fixtures["discovery"]["deviceSnapshot"] + assert snapshot["version"] == 1 + assert snapshot["type"] == "route_snapshot" + assert snapshot["sourceRoute"]["role"] == "device" + assert [%{"role" => "daemon"}] = snapshot["peers"] + refute Map.has_key?(hd(snapshot["peers"]), "deviceId") + end + test "forms the admitted WebSocket message without relay-owned fields" do consume = @fixtures["consumeTicket"]["request"] diff --git a/services/relay/test/route_registry_test.exs b/services/relay/test/route_registry_test.exs index 8c4ed0cf..c5309ad0 100644 --- a/services/relay/test/route_registry_test.exs +++ b/services/relay/test/route_registry_test.exs @@ -24,6 +24,8 @@ defmodule AxlRelay.RouteRegistryTest do RouteRegistry.register(registry, source, %{ installation_id: @installation, device_id: nil, + role: :daemon, + grant_generation: 1, source_route_id: @source, limits: limits }) @@ -32,6 +34,8 @@ defmodule AxlRelay.RouteRegistryTest do RouteRegistry.register(registry, destination, %{ installation_id: @installation, device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + role: :device, + grant_generation: 1, source_route_id: @destination, limits: limits }) @@ -60,7 +64,9 @@ defmodule AxlRelay.RouteRegistryTest do assert :ok = RouteRegistry.register(registry, other, %{ installation_id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", - device_id: nil, + device_id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + role: :device, + grant_generation: 1, source_route_id: other_route, limits: %{max_queued_bytes: 50} }) @@ -69,6 +75,84 @@ defmodule AxlRelay.RouteRegistryTest do RouteRegistry.forward(registry, @source, other_route, @attempt, <<1>>) end + test "rejects same-role routing and replaces an older device identity", %{registry: registry} do + parent = self() + second_device = spawn_link(fn -> forward_messages(parent, :second_device) end) + second_route = "66666666-6666-4666-8666-666666666666" + + assert :ok = + RouteRegistry.register(registry, second_device, %{ + installation_id: @installation, + device_id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + role: :device, + grant_generation: 1, + source_route_id: second_route, + limits: %{max_queued_bytes: 50} + }) + + assert {:error, :forbidden_route} = + RouteRegistry.forward(registry, @destination, second_route, @attempt, <<1>>) + + replacement = spawn_link(fn -> forward_messages(parent, :replacement) end) + replacement_route = "77777777-7777-4777-8777-777777777777" + + assert :ok = + RouteRegistry.register(registry, replacement, %{ + installation_id: @installation, + device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + role: :device, + grant_generation: 1, + source_route_id: replacement_route, + limits: %{max_queued_bytes: 50} + }) + + assert_receive {:destination, :route_replaced} + refute Map.has_key?(RouteRegistry.snapshot(registry).routes, @destination) + assert Map.has_key?(RouteRegistry.snapshot(registry).routes, replacement_route) + end + + test "evicts a queue that remains above half after saturation" do + registry = + start_supervised!( + Supervisor.child_spec( + {RouteRegistry, name: nil, slow_consumer_grace_ms: 10}, + id: make_ref() + ) + ) + + parent = self() + daemon = spawn_link(fn -> forward_messages(parent, :slow_daemon) end) + device = spawn_link(fn -> forward_messages(parent, :slow_device) end) + + assert :ok = + RouteRegistry.register(registry, daemon, %{ + installation_id: @installation, + device_id: nil, + role: :daemon, + grant_generation: 1, + source_route_id: @source, + limits: %{max_queued_bytes: 50} + }) + + assert :ok = + RouteRegistry.register(registry, device, %{ + installation_id: @installation, + device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + role: :device, + grant_generation: 1, + source_route_id: @destination, + limits: %{max_queued_bytes: 50} + }) + + assert :ok = RouteRegistry.forward(registry, @source, @destination, @attempt, <<1, 2, 3>>) + + assert {:error, :queue_full} = + RouteRegistry.forward(registry, @source, @destination, @attempt, <<4>>) + + assert_receive {:slow_device, :slow_consumer}, 100 + refute Map.has_key?(RouteRegistry.snapshot(registry).routes, @destination) + end + test "revocation closes matching routes and draining rejects admission", %{registry: registry} do assert :ok = RouteRegistry.revoke(registry, %{ @@ -80,6 +164,16 @@ defmodule AxlRelay.RouteRegistryTest do assert_receive {:destination, :route_revoked} refute Map.has_key?(RouteRegistry.snapshot(registry).routes, @destination) + assert {:error, :ticket_revoked} = + RouteRegistry.register(registry, self(), %{ + installation_id: @installation, + device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + role: :device, + grant_generation: 1, + source_route_id: "88888888-8888-4888-8888-888888888888", + limits: %{max_queued_bytes: 50} + }) + assert :ok = RouteRegistry.drain(registry) assert_receive {:source, :relay_draining} @@ -87,6 +181,8 @@ defmodule AxlRelay.RouteRegistryTest do RouteRegistry.register(registry, self(), %{ installation_id: @installation, device_id: nil, + role: :daemon, + grant_generation: 1, source_route_id: "55555555-5555-4555-8555-555555555555", limits: %{max_queued_bytes: 50} }) diff --git a/services/relay/test/websocket_relay_test.exs b/services/relay/test/websocket_relay_test.exs index 8f0c5bf7..35ecdc21 100644 --- a/services/relay/test/websocket_relay_test.exs +++ b/services/relay/test/websocket_relay_test.exs @@ -4,7 +4,7 @@ defmodule AxlRelay.WebSocketRelayTest do use ExUnit.Case, async: false - alias AxlRelay.{Frame, Listener, RouteRegistry} + alias AxlRelay.{Connection, Frame, Listener, RouteRegistry} @daemon_route "11111111-1111-4111-8111-111111111111" @device_route "22222222-2222-4222-8222-222222222222" @@ -17,6 +17,24 @@ defmodule AxlRelay.WebSocketRelayTest do def consume_ticket(%{"ticket" => "unavailable"}, _relay_instance_id, _options), do: {:error, :service_unavailable} + def consume_ticket(%{"ticket" => "half-open"}, _relay_instance_id, options) do + {:ok, + %{ + installation_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + source_route_id: Keyword.fetch!(options, :device), + role: :device, + grant_generation: 1, + lease_expires_at: System.system_time(:millisecond) + 60_000, + limits: %{ + max_frame_bytes: 65_535, + max_queued_bytes: 524_288, + heartbeat_interval_ms: 10, + idle_timeout_ms: 30 + } + }} + end + def consume_ticket(%{"ticket" => ticket}, _relay_instance_id, options) when ticket in ["daemon", "device"] do role = if ticket == "daemon", do: :daemon, else: :device @@ -32,6 +50,7 @@ defmodule AxlRelay.WebSocketRelayTest do ), source_route_id: route_id, role: role, + grant_generation: 1, lease_expires_at: System.system_time(:millisecond) + 60_000, limits: %{ max_frame_bytes: 65_535, @@ -84,6 +103,9 @@ defmodule AxlRelay.WebSocketRelayTest do assert_eventually(fn -> map_size(RouteRegistry.snapshot(registry).routes) == 2 end) + expect_discovered_peer(daemon, @daemon_route, "device", @device_route) + expect_discovered_peer(device, @device_route, "daemon", @daemon_route) + assert {:ok, send_frame} = Frame.encode(%{ kind: :send, @@ -113,6 +135,34 @@ defmodule AxlRelay.WebSocketRelayTest do :gen_tcp.close(daemon) end + test "closes a half-open connection after the explicit inbound idle deadline" do + registry = + start_supervised!(Supervisor.child_spec({RouteRegistry, name: nil}, id: make_ref())) + + {:ok, state} = + Connection.init( + control_plane: FakeControlPlane, + control_plane_options: [device: @device_route], + relay_instance_id: "relay-test", + registry: registry + ) + + admission = + :json.encode(%{ + "version" => 1, + "ticket" => "half-open", + "connectionNonce" => "fixture-nonce", + "possessionProof" => "AAECA/8=" + }) + |> IO.iodata_to_binary() + + assert {:ok, active} = Connection.handle_in({admission, opcode: :binary}, state) + Process.sleep(35) + + assert {:stop, :normal, {1008, "idle_timeout"}, _state} = + Connection.handle_info(:heartbeat, active) + end + test "fails admission closed when the control plane is unavailable", %{ registry: registry, port: port @@ -133,6 +183,8 @@ defmodule AxlRelay.WebSocketRelayTest do RouteRegistry.register(registry, self(), %{ installation_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + role: :device, + grant_generation: 1, source_route_id: route, limits: %{max_queued_bytes: 524_288} }) @@ -217,6 +269,28 @@ defmodule AxlRelay.WebSocketRelayTest do <<0x82, encoded_length::binary, mask::binary, masked::binary>> end + defp expect_discovered_peer(socket, source_route, peer_role, peer_route) do + assert %{ + "type" => "route_snapshot", + "sourceRoute" => %{"routeId" => ^source_route}, + "peers" => peers + } = receive_json_message(socket) + + if peers == [] do + assert %{ + "type" => "route_available", + "peers" => [%{"role" => ^peer_role, "routeId" => ^peer_route}] + } = + receive_json_message(socket) + else + assert [%{"role" => ^peer_role, "routeId" => ^peer_route}] = peers + end + end + + defp receive_json_message(socket) do + socket |> receive_binary_frame() |> :json.decode() + end + defp receive_binary_frame(socket) do {:ok, <<0x82, length>>} = :gen_tcp.recv(socket, 2, 2_000) From ac9548f5303a87d5ac9c8a42babdf6d375773e6c Mon Sep 17 00:00:00 2001 From: Lokesh Date: Sat, 12 Sep 2026 18:35:53 +0400 Subject: [PATCH 09/16] feat(daemon): add durable remote device authority Signed-off-by: Lokesh --- ROADMAP.md | 14 + docs/architecture/remote-daemon-authority.md | 77 +++ packages/daemon/README.md | 3 + packages/daemon/src/command-journal.ts | 43 +- packages/daemon/src/daemon.ts | 185 +++++- packages/daemon/src/index.ts | 2 + packages/daemon/src/remote-authority.ts | 539 ++++++++++++++++++ packages/daemon/src/remote-rpc.ts | 34 ++ packages/daemon/test/remote-authority.test.ts | 401 +++++++++++++ packages/protocol/src/remote-transport.ts | 27 + .../protocol/test/remote-transport.test.ts | 13 + 11 files changed, 1315 insertions(+), 23 deletions(-) create mode 100644 docs/architecture/remote-daemon-authority.md create mode 100644 packages/daemon/src/remote-authority.ts create mode 100644 packages/daemon/src/remote-rpc.ts create mode 100644 packages/daemon/test/remote-authority.test.ts diff --git a/ROADMAP.md b/ROADMAP.md index 24d2d4c1..ebc6aec0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2366,6 +2366,20 @@ The private slice was created from clean `main` commit `ea906d0295ba67f833c49ace - [x] Draft the daemon-owned remote permission action-binding contract without enabling it. - [x] Stop at the architecture checkpoint before daemon, SDK, prekey, attachment, or production integration work. +#### Remote daemon authority checkpoint + +The transport checkpoint was approved. The next private slice remains disabled for ordinary sessions and uses only the test fake E2EE adapter. + +- [x] Define independent `observe`, `steer`, `approve_within_policy`, and `manage_sessions` scopes. +- [x] Persist installation-bound local device grants and hosted narrowing generations in the daemon data directory. +- [x] Authorize from the intersection of current local and hosted grants. +- [x] Make local and hosted revocation terminal for one device identity. +- [x] Reject stale and conflicting hosted generations atomically. +- [x] Verify authorization before the existing durable command-idempotency path with fake E2EE fixtures. +- [x] Map each remotely callable daemon RPC to an explicit scope and connect the internal dispatcher. +- [ ] Implement the reviewed permission-action events and RPC after protocol review. +- [ ] Keep relay, runtime, CLI, SDK, and ordinary-session wiring disabled until their later gates. + #### Mobile clients - [ ] Choose mobile implementation stacks when work begins, based on concrete platform and product requirements. diff --git a/docs/architecture/remote-daemon-authority.md b/docs/architecture/remote-daemon-authority.md new file mode 100644 index 00000000..60776849 --- /dev/null +++ b/docs/architecture/remote-daemon-authority.md @@ -0,0 +1,77 @@ + + + +# Remote daemon authority + +Status: approved infrastructure behind test-only fake E2EE + +## Scope + +This slice establishes durable installation-scoped device authority without enabling a network remote transport in the daemon. `packages/daemon/src/remote-authority.ts` owns the local record and effective grant calculation. An internal authenticated attachment connects an explicitly allowlisted subset of existing RPCs to the same daemon dispatcher and command journal. The relay and control plane cannot widen daemon authority. + +The processing contract remains: + +```text +open through injected endpoint crypto + -> authenticated device identity + -> validate typed request + -> load current grants and revocation + -> authorize the required remote scope + -> apply daemon command idempotency + -> durably accept + -> execute +``` + +Tests exercise this ordering with the deterministic fake E2EE adapter, internal authenticated attachment, and daemon command journal. Production code does not import or expose the fake adapter. + +## Grant model + +A paired device has two independent grants: + +- **local grant:** created by the authoritative daemon during pairing +- **hosted grant:** a control-plane restriction delivered with a monotonic generation + +Effective scopes are the exact intersection. A hosted grant can never add a scope absent from the local grant. Missing hosted state fails closed for hosted remote access. + +Initial scope identifiers are: + +```text +observe +steer +approve_within_policy +manage_sessions +``` + +Scopes are independent. Holding `steer` does not implicitly grant `observe`, approval, or session management. + +## Persistence + +The daemon stores `remote-authority.json` under its protected data directory with mode `0600`. Writes use a private temporary file, file synchronization, atomic rename, and directory synchronization. Readers reject symlinks, non-regular files, files above 1 MiB before parsing, malformed records, more than 256 devices, duplicate device IDs, unknown scopes, invalid identifiers, and installation-identity mismatch. + +The record contains no private key, credential, relay ticket, ratchet state, ciphertext, prompt, or command body. + +## Generations and revocation + +Local and hosted grants have separate positive generations. A hosted update must have a greater generation, or be a byte-equivalent retry of the current generation. A conflicting equal generation and every lower generation fail. + +Revocation is terminal for one device identity. Neither a local re-registration nor a later hosted grant may restore it. Restoring access requires a new pairing and new device identity. This avoids reviving a lost-device key through stale or compromised hosted state. + +Every request reloads the current in-memory state after serialized durable updates. Revocation prevents new authorization. Work already durably accepted remains daemon-owned. + +## Internal RPC mapping + +The internal attachment supports only methods listed in `packages/daemon/src/remote-rpc.ts`. Observation methods require `observe`; send, steering, queued-input, and interrupt methods require `steer`. Direct shell execution, provider authentication, generic MCP interaction responses, session administration, blob upload, configuration changes, and every unknown or future method are denied by default. + +Retryable mutations enter the existing daemon command journal while the authority store serializes grant checks through durable acceptance. Revocation may proceed immediately after acceptance without waiting for operation completion. Non-mutating requests recheck authority immediately before dispatch. + +## Current non-capabilities + +This module is not wired to the relay, runtime, CLI, SDK, or ordinary sessions. It does not: + +- authenticate cryptography +- define pairing or key storage +- close a relay route through a production transport +- implement permission interactions +- enable remote control + +Those integrations remain behind later review gates. diff --git a/packages/daemon/README.md b/packages/daemon/README.md index 6aad0fba..85441f2b 100644 --- a/packages/daemon/README.md +++ b/packages/daemon/README.md @@ -1,5 +1,6 @@ + # `@axl/daemon` @@ -7,3 +8,5 @@ The daemon owns sessions, agent loops, event logs, and active operations. Clients connect through a local Unix socket, load bounded history pages, and follow the live event stream. They do not keep their own copy of the agent loop. The local wire protocol uses newline-delimited JSON and requires an exact `WIRE_PROTOCOL_VERSION` match. It currently supports daemon security and sandbox identity, session creation and profiles, listing, paged history, resume, fork, clone, manual compaction, daemon-owned steering and follow-ups, subscriptions, turns, interruption, reload, live activity, abortable blob transport, workspace review, model, thinking, and web-tool configuration, and user interactions requested by extensions. Phase 9 adds the full RPC surface and generated SDK. + +Remote-authority infrastructure persists local device grants, intersects them with hosted narrowing grants, and enforces terminal revocation. An internal authenticated attachment maps an explicit RPC subset to `observe` or `steer` and reuses the existing dispatcher and durable command journal. It is not connected to a relay or ordinary session and does not provide cryptography. Its current integration coverage uses only the test fake E2EE adapter. diff --git a/packages/daemon/src/command-journal.ts b/packages/daemon/src/command-journal.ts index 5bbef511..7463b56d 100644 --- a/packages/daemon/src/command-journal.ts +++ b/packages/daemon/src/command-journal.ts @@ -219,6 +219,11 @@ async function appendSynced(path: string, record: CommandRecord): Promise } } +export interface StartedCommand { + readonly acceptance: Promise; + readonly completion: Promise; +} + export class CommandJournal { readonly path: string; private readonly entries = new Map(); @@ -315,17 +320,36 @@ export class CommandJournal { }, effect: (acceptance: CommandAcceptance) => Promise>, ): Promise> { - return this.accept(input).then((entry) => { - const completion = entry.completion; - if (completion?.type === "succeeded") { - return parseRpcResult(input.method, completion.result); + const started = this.start(input, effect); + void started.acceptance.catch(() => undefined); + return started.completion; + } + + start( + input: { + readonly idempotencyKey: string; + readonly method: Method; + readonly requestHash: string; + readonly targetSessionId?: SessionId; + readonly intendedSessionId?: SessionId; + readonly affectedOperationId?: string; + readonly interactionId?: string; + }, + effect: (acceptance: CommandAcceptance) => Promise>, + ): StartedCommand> { + const entryPromise = this.accept(input); + const acceptance = entryPromise.then((entry) => entry.acceptance); + const completion = entryPromise.then((entry) => { + const persisted = entry.completion; + if (persisted?.type === "succeeded") { + return parseRpcResult(input.method, persisted.result); } - if (completion?.type === "failed") { + if (persisted?.type === "failed") { throw new CommandJournalError( - completion.error.code, - completion.error.message, - completion.error.retryable, - completion.error.details, + persisted.error.code, + persisted.error.message, + persisted.error.retryable, + persisted.error.details, ); } if (entry.running !== undefined) return entry.running as Promise>; @@ -384,6 +408,7 @@ export class CommandJournal { ); return running; }); + return { acceptance, completion }; } private accept(input: { diff --git a/packages/daemon/src/daemon.ts b/packages/daemon/src/daemon.ts index 3c06a64a..56d9d29e 100644 --- a/packages/daemon/src/daemon.ts +++ b/packages/daemon/src/daemon.ts @@ -13,7 +13,9 @@ import { StringDecoder } from "node:string_decoder"; import { type AttachmentPresence, + type AuthenticatedRemoteRequest, type DaemonHostStatus, + type DeviceId, type HostContext, type HostResponse, HOST_CONTROL_VERSION, @@ -32,10 +34,13 @@ import { MAX_CANONICAL_EVENT_BYTES, MAX_WIRE_MESSAGE_BYTES, ProtocolValidationError, + parseAuthenticatedRemoteRequest, parseOperationId, parseRpcResult, parseSessionId, parseWireRequest, + type RemoteDeviceScope, + type RequestId, type RetryableMutationMethod, RPC_METHODS, requiredCapability, @@ -54,6 +59,8 @@ import { commandCatalog } from "./command-catalog.ts"; import { type CommandAcceptance, CommandJournal, CommandJournalError } from "./command-journal.ts"; import { DataDirectoryLock } from "./data-directory-lock.ts"; import type { ProviderManagementService } from "./provider-management.ts"; +import { RemoteAuthorityError, type RemoteDeviceAuthorityStore } from "./remote-authority.ts"; +import { requiredRemoteScope } from "./remote-rpc.ts"; import { DaemonError, SessionManager, type SessionManagerOptions } from "./session-manager.ts"; export type DaemonSecurityMode = "sandboxed" | "unsafe"; @@ -74,6 +81,29 @@ export interface DaemonOptions extends SessionManagerOptions { readonly providerManagement?: ProviderManagementService; } +export interface AuthenticatedRemoteAttachmentOptions { + readonly deviceId: DeviceId; + readonly authority: RemoteDeviceAuthorityStore; + readonly send: (message: ServerMessage) => void; +} + +export interface AuthenticatedRemoteRequestResult { + readonly requestId: RequestId; + readonly method: WireRequest["method"]; + readonly result: unknown; +} + +export interface AuthenticatedRemoteAttachment { + request(value: unknown): Promise; + close(): void; +} + +interface RemoteExecutionAuthority { + readonly store: RemoteDeviceAuthorityStore; + readonly deviceId: DeviceId; + readonly scope: RemoteDeviceScope; +} + const MAX_PENDING_REQUESTS = 64; const MAX_ATTACHMENTS = 256; const MAX_SNAPSHOT_PAGE_BYTES = MAX_CANONICAL_EVENT_BYTES; @@ -225,6 +255,8 @@ export class AxlDaemon { private socketIdentity: SocketIdentity | undefined; private readonly connections = new Set(); private readonly connectionStates = new Set(); + private readonly remoteConnectionStates = new Set(); + private readonly remoteAttachmentClosers = new Set<() => void>(); private readonly cursors = new Map(); private sessionCatalogGeneration = 0; @@ -332,7 +364,7 @@ export class AxlDaemon { if (this.stopping !== undefined) return this.stopping; this.lifecycle = "stopping"; this.sessions.beginShutdown(); - for (const state of this.connectionStates) { + for (const state of [...this.connectionStates, ...this.remoteConnectionStates]) { for (const controller of state.cancellableRequests.values()) controller.abort(); } this.stopping = this.finishShutdown().catch((error: unknown) => { @@ -343,6 +375,121 @@ export class AxlDaemon { return this.stopping; } + attachAuthenticatedRemoteDevice( + options: AuthenticatedRemoteAttachmentOptions, + ): AuthenticatedRemoteAttachment { + if (this.lifecycle !== "running" || this.commandJournal === undefined) { + throw new DaemonError("daemon_stopping", "Daemon is not accepting remote attachments"); + } + let closed = false; + let nextWireRequestId = 0; + const state: ConnectionState = { + initialized: true, + control: false, + attachmentId: randomUUID(), + client: { kind: "remote", version: "internal", instanceId: options.deviceId }, + connectedAt: Date.now(), + lastSeenAt: Date.now(), + grantedCapabilities: new Set(this.capabilities), + pendingRequests: 0, + cancellableRequests: new Map(), + sessionListPages: new Map(), + subscriptions: new Map(), + send: (message) => { + if (!closed) options.send(message); + }, + }; + this.remoteConnectionStates.add(state); + const close = (): void => { + if (closed) return; + closed = true; + for (const controller of state.cancellableRequests.values()) controller.abort(); + for (const subscription of state.subscriptions.values()) subscription.unsubscribe(); + state.cancellableRequests.clear(); + state.subscriptions.clear(); + this.remoteConnectionStates.delete(state); + this.remoteAttachmentClosers.delete(close); + removeRevocationListener(); + }; + const removeRevocationListener = options.authority.onDeviceRevoked((deviceId) => { + if (deviceId === options.deviceId) close(); + }); + this.remoteAttachmentClosers.add(close); + + return { + request: (value) => { + const operation = (async (): Promise => { + if (closed) + throw new RemoteAuthorityError("device_revoked", "Remote attachment is closed"); + if (state.pendingRequests >= MAX_PENDING_REQUESTS) { + throw new DaemonError("rate_limited", "Too many pending remote requests"); + } + const remoteRequest: AuthenticatedRemoteRequest = parseAuthenticatedRemoteRequest(value); + if (remoteRequest.deviceId !== options.deviceId) { + throw new RemoteAuthorityError( + "device_identity_mismatch", + "Authenticated device does not match the request", + ); + } + const wireRequest = parseWireRequest({ + kind: "request", + id: nextWireRequestId, + method: remoteRequest.method, + params: remoteRequest.params, + ...(remoteRequest.idempotencyKey === undefined + ? {} + : { idempotencyKey: remoteRequest.idempotencyKey }), + }); + nextWireRequestId = + nextWireRequestId === Number.MAX_SAFE_INTEGER ? 0 : nextWireRequestId + 1; + const scope = requiredRemoteScope(wireRequest.method); + if (scope === undefined) { + throw new RemoteAuthorityError( + "remote_method_forbidden", + "RPC method is not available to remote devices", + ); + } + if (this.securityMode === "unsafe" && scope !== "observe") { + throw new RemoteAuthorityError( + "unsafe_remote_forbidden", + "Remote mutation is unavailable for unsafe sessions", + ); + } + + state.pendingRequests += 1; + state.lastSeenAt = Date.now(); + const admissionId = randomUUID(); + this.admitted.set(admissionId, wireRequest); + try { + const result = await this.executeRequest(wireRequest, state.send, state, undefined, { + store: options.authority, + deviceId: options.deviceId, + scope, + }); + const validated = parseRpcResult(wireRequest.method, result); + this.activateReadySubscriptions(state, state.send); + return { + requestId: remoteRequest.requestId, + method: wireRequest.method, + result: validated, + }; + } finally { + this.admitted.delete(admissionId); + state.pendingRequests -= 1; + } + })(); + const tracked = operation.then( + () => undefined, + () => undefined, + ); + this.pending.add(tracked); + void tracked.finally(() => this.pending.delete(tracked)); + return operation; + }, + close, + }; + } + private async finishShutdown(): Promise { // Every request admitted before the gate must finish its journal outcome first. await Promise.all([...this.pending]); @@ -353,7 +500,7 @@ export class AxlDaemon { await this.removeOwnedSocket(); this.lifecycle = "stopped"; this.cursors.clear(); - for (const state of this.connectionStates) { + for (const state of [...this.connectionStates, ...this.remoteConnectionStates]) { if (!state.control) state.send({ kind: "error", @@ -365,6 +512,7 @@ export class AxlDaemon { }, }); } + for (const close of [...this.remoteAttachmentClosers]) close(); const server = this.server; this.server = undefined; if (server?.listening) server.close(() => this.hostOptions.onStopped?.()); @@ -894,6 +1042,7 @@ export class AxlDaemon { send: (message: ServerMessage) => void, state: ConnectionState, signal?: AbortSignal, + remoteAuthority?: RemoteExecutionAuthority, ): Promise { let normalized = request; if (request.method === "session.create") { @@ -930,6 +1079,7 @@ export class AxlDaemon { normalized = { ...request, params: { ...request.params, cwd } }; } if (!isRetryableMutationMethod(normalized.method)) { + remoteAuthority?.store.authorize(remoteAuthority.deviceId, remoteAuthority.scope); return this.dispatch(normalized, send, state, undefined, signal); } const idempotencyKey = normalized.idempotencyKey; @@ -966,19 +1116,26 @@ export class AxlDaemon { affectedOperationId, ); } + const journalInput = { + idempotencyKey, + method: normalized.method as RetryableMutationMethod, + requestHash: hashCanonicalRequest(normalized.method, normalized.params as never), + ...(params.sessionId === undefined ? {} : { targetSessionId: params.sessionId }), + ...(intendedSessionId === undefined ? {} : { intendedSessionId }), + ...(affectedOperationId === undefined ? {} : { affectedOperationId }), + ...(interactionId === undefined ? {} : { interactionId }), + }; + const effect = (acceptance: CommandAcceptance) => + this.dispatch(normalized, send, state, acceptance) as never; try { - return await journal.execute( - { - idempotencyKey, - method: normalized.method as RetryableMutationMethod, - requestHash: hashCanonicalRequest(normalized.method, normalized.params as never), - ...(params.sessionId === undefined ? {} : { targetSessionId: params.sessionId }), - ...(intendedSessionId === undefined ? {} : { intendedSessionId }), - ...(affectedOperationId === undefined ? {} : { affectedOperationId }), - ...(interactionId === undefined ? {} : { interactionId }), - }, - (acceptance) => this.dispatch(normalized, send, state, acceptance) as never, - ); + if (remoteAuthority !== undefined) { + return await remoteAuthority.store.runAuthorizedUntilAccepted( + remoteAuthority.deviceId, + remoteAuthority.scope, + () => journal.start(journalInput, effect), + ); + } + return await journal.execute(journalInput, effect); } finally { if (interruptDeliveryOperationId !== undefined) { this.sessions.releaseInterruptDelivery(params.sessionId, interruptDeliveryOperationId); diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index 85d520fe..dc345abd 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -5,5 +5,7 @@ export * from "./daemon.ts"; export * from "./event-migration.ts"; export * from "./provider-management.ts"; +export * from "./remote-authority.ts"; +export * from "./remote-rpc.ts"; export * from "./session-manager.ts"; export type { WireEvent } from "@axl/protocol"; diff --git a/packages/daemon/src/remote-authority.ts b/packages/daemon/src/remote-authority.ts new file mode 100644 index 00000000..cd7ee03f --- /dev/null +++ b/packages/daemon/src/remote-authority.ts @@ -0,0 +1,539 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import { constants, type Stats } from "node:fs"; +import { lstat, mkdir, open, rename, rm } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { randomUUID } from "node:crypto"; + +import { + parseDeviceId, + parseInstallationId, + parseRemoteDeviceScopes, + type DeviceId, + type InstallationId, + type RemoteDeviceScope, +} from "@axl/protocol"; + +const AUTHORITY_FORMAT_VERSION = 1 as const; +const AUTHORITY_FILE_NAME = "remote-authority.json"; +const MAX_REMOTE_AUTHORITY_BYTES = 1024 * 1024; +const MAX_REMOTE_DEVICES = 256; + +interface GrantState { + readonly generation: number; + readonly scopes: readonly RemoteDeviceScope[]; + readonly revokedAt?: number; +} + +interface DeviceAuthorityRecord { + readonly deviceId: DeviceId; + readonly createdAt: number; + readonly local: GrantState; + readonly hosted?: GrantState; +} + +interface PersistedAuthorityState { + readonly version: typeof AUTHORITY_FORMAT_VERSION; + readonly installationId: InstallationId; + readonly devices: readonly DeviceAuthorityRecord[]; +} + +export interface RemoteDeviceAuthoritySnapshot { + readonly deviceId: DeviceId; + readonly createdAt: number; + readonly localGeneration: number; + readonly hostedGeneration?: number; + readonly localScopes: readonly RemoteDeviceScope[]; + readonly hostedScopes?: readonly RemoteDeviceScope[]; + readonly effectiveScopes: readonly RemoteDeviceScope[]; + readonly locallyRevoked: boolean; + readonly hostedRevoked: boolean; +} + +export interface RemoteAuthorizationContext { + readonly installationId: InstallationId; + readonly deviceId: DeviceId; + readonly localGrantGeneration: number; + readonly hostedGrantGeneration: number; + readonly effectiveScopes: readonly RemoteDeviceScope[]; +} + +export interface StartedAuthorizedOperation { + /** Resolves only after durable command acceptance. */ + readonly acceptance: Promise; + readonly completion: Promise; +} + +export type RemoteAuthorityErrorCode = + | "unknown_device" + | "device_revoked" + | "hosted_grant_missing" + | "scope_forbidden" + | "grant_conflict" + | "stale_grant_generation" + | "device_limit_reached" + | "device_identity_mismatch" + | "remote_method_forbidden" + | "unsafe_remote_forbidden"; + +export class RemoteAuthorityError extends Error { + readonly code: RemoteAuthorityErrorCode; + + constructor(code: RemoteAuthorityErrorCode, message: string) { + super(message); + this.name = "RemoteAuthorityError"; + this.code = code; + } +} + +function object(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${path} must be an object`); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error(`${path} must be a plain object`); + } + return value as Record; +} + +function exact( + value: Record, + path: string, + required: readonly string[], + optional: readonly string[] = [], +): void { + const allowed = new Set([...required, ...optional]); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) throw new Error(`${path}.${key} is unknown`); + } + for (const key of required) { + if (!(key in value)) throw new Error(`${path}.${key} is required`); + } +} + +function nonNegativeInteger(value: unknown, path: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new Error(`${path} must be a non-negative safe integer`); + } + return value as number; +} + +function positiveInteger(value: unknown, path: string): number { + const parsed = nonNegativeInteger(value, path); + if (parsed === 0) throw new Error(`${path} must be positive`); + return parsed; +} + +function parseGrant(value: unknown, path: string): GrantState { + const grant = object(value, path); + exact(grant, path, ["generation", "scopes"], ["revokedAt"]); + return { + generation: positiveInteger(grant.generation, `${path}.generation`), + scopes: parseRemoteDeviceScopes(grant.scopes, `${path}.scopes`), + ...(grant.revokedAt === undefined + ? {} + : { revokedAt: nonNegativeInteger(grant.revokedAt, `${path}.revokedAt`) }), + }; +} + +function parseAuthorityState(value: unknown): PersistedAuthorityState { + const state = object(value, "remote authority"); + exact(state, "remote authority", ["version", "installationId", "devices"]); + if (state.version !== AUTHORITY_FORMAT_VERSION) { + throw new Error(`remote authority.version must be ${AUTHORITY_FORMAT_VERSION}`); + } + if (!Array.isArray(state.devices)) throw new Error("remote authority.devices must be an array"); + if (state.devices.length > MAX_REMOTE_DEVICES) { + throw new Error(`remote authority.devices must not exceed ${MAX_REMOTE_DEVICES} entries`); + } + const seen = new Set(); + const devices = state.devices.map((value, index): DeviceAuthorityRecord => { + const path = `remote authority.devices[${index}]`; + const device = object(value, path); + exact(device, path, ["deviceId", "createdAt", "local"], ["hosted"]); + const deviceId = parseDeviceId(device.deviceId, `${path}.deviceId`); + if (seen.has(deviceId)) throw new Error(`${path}.deviceId is duplicated`); + seen.add(deviceId); + return { + deviceId, + createdAt: nonNegativeInteger(device.createdAt, `${path}.createdAt`), + local: parseGrant(device.local, `${path}.local`), + ...(device.hosted === undefined + ? {} + : { hosted: parseGrant(device.hosted, `${path}.hosted`) }), + }; + }); + return { + version: AUTHORITY_FORMAT_VERSION, + installationId: parseInstallationId(state.installationId, "remote authority.installationId"), + devices, + }; +} + +function canonicalScopes(scopes: readonly RemoteDeviceScope[]): readonly RemoteDeviceScope[] { + return parseRemoteDeviceScopes(scopes); +} + +function nextGeneration(current: number): number { + if (current >= Number.MAX_SAFE_INTEGER) { + throw new RemoteAuthorityError("grant_conflict", "Local grant generation is exhausted"); + } + return current + 1; +} + +function sameScopes( + left: readonly RemoteDeviceScope[], + right: readonly RemoteDeviceScope[], +): boolean { + return left.length === right.length && left.every((scope, index) => scope === right[index]); +} + +function effectiveScopes(record: DeviceAuthorityRecord): readonly RemoteDeviceScope[] { + if (record.local.revokedAt !== undefined || record.hosted?.revokedAt !== undefined) return []; + const hosted = new Set(record.hosted?.scopes ?? []); + return record.local.scopes.filter((scope) => hosted.has(scope)); +} + +async function readExisting(path: string): Promise { + let status: Stats; + try { + status = await lstat(path); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined; + throw error; + } + if (!status.isFile() || status.isSymbolicLink()) { + throw new Error(`Remote authority path ${JSON.stringify(path)} must be a regular file`); + } + if (status.size > MAX_REMOTE_AUTHORITY_BYTES) { + throw new Error(`Remote authority store exceeds ${MAX_REMOTE_AUTHORITY_BYTES} bytes`); + } + const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0; + const handle = await open(path, constants.O_RDONLY | noFollow); + try { + const openedStatus = await handle.stat(); + if (!openedStatus.isFile()) + throw new Error("Remote authority store must remain a regular file"); + if (openedStatus.size > MAX_REMOTE_AUTHORITY_BYTES) { + throw new Error(`Remote authority store exceeds ${MAX_REMOTE_AUTHORITY_BYTES} bytes`); + } + await handle.chmod(0o600); + return await handle.readFile(); + } finally { + await handle.close(); + } +} + +async function writeAtomic(path: string, state: PersistedAuthorityState): Promise { + const directory = dirname(path); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const temporary = `${path}.${randomUUID()}.tmp`; + const bytes = new TextEncoder().encode(`${JSON.stringify(state)}\n`); + try { + const handle = await open(temporary, "wx", 0o600); + try { + await handle.writeFile(bytes); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temporary, path); + const directoryHandle = await open(directory, "r"); + try { + await directoryHandle.sync(); + } finally { + await directoryHandle.close(); + } + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } +} + +/** Durable local and hosted device-grant intersection for remote daemon requests. */ +export class RemoteDeviceAuthorityStore { + readonly path: string; + readonly installationId: InstallationId; + private devices: Map; + private readonly revocationListeners = new Set<(deviceId: DeviceId) => void>(); + private tail: Promise = Promise.resolve(); + + private constructor(path: string, state: PersistedAuthorityState) { + this.path = path; + this.installationId = state.installationId; + this.devices = new Map(state.devices.map((record) => [record.deviceId, record])); + } + + static async open( + dataDirectory: string, + installationId: InstallationId, + ): Promise { + const path = resolve(dataDirectory, AUTHORITY_FILE_NAME); + const bytes = await readExisting(path); + if (bytes === undefined) { + const initial: PersistedAuthorityState = { + version: AUTHORITY_FORMAT_VERSION, + installationId, + devices: [], + }; + await writeAtomic(path, initial); + return new RemoteDeviceAuthorityStore(path, initial); + } + let state: PersistedAuthorityState; + try { + state = parseAuthorityState( + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)), + ); + } catch (cause) { + throw new Error(`Corrupt remote authority store ${JSON.stringify(path)}`, { cause }); + } + if (state.installationId !== installationId) { + throw new Error("Remote authority installation identity does not match this daemon"); + } + return new RemoteDeviceAuthorityStore(path, state); + } + + snapshot(deviceId: DeviceId): RemoteDeviceAuthoritySnapshot | undefined { + const record = this.devices.get(deviceId); + if (record === undefined) return undefined; + return { + deviceId: record.deviceId, + createdAt: record.createdAt, + localGeneration: record.local.generation, + ...(record.hosted === undefined ? {} : { hostedGeneration: record.hosted.generation }), + localScopes: [...record.local.scopes], + ...(record.hosted === undefined ? {} : { hostedScopes: [...record.hosted.scopes] }), + effectiveScopes: effectiveScopes(record), + locallyRevoked: record.local.revokedAt !== undefined, + hostedRevoked: record.hosted?.revokedAt !== undefined, + }; + } + + registerLocalDevice( + deviceId: DeviceId, + scopes: readonly RemoteDeviceScope[], + now = Date.now(), + ): Promise { + return this.mutate((devices) => { + const normalizedScopes = canonicalScopes(scopes); + const existing = devices.get(deviceId); + if (existing === undefined && devices.size >= MAX_REMOTE_DEVICES) { + throw new RemoteAuthorityError("device_limit_reached", "Device limit has been reached"); + } + if (existing !== undefined) { + if ( + existing.local.revokedAt !== undefined || + !sameScopes(existing.local.scopes, normalizedScopes) + ) { + throw new RemoteAuthorityError( + "grant_conflict", + "Device identity is already bound to another local grant", + ); + } + return devices; + } + devices.set(deviceId, { + deviceId, + createdAt: nonNegativeInteger(now, "now"), + local: { generation: 1, scopes: normalizedScopes }, + }); + return devices; + }).then(() => this.requiredSnapshot(deviceId)); + } + + narrowLocalGrant( + deviceId: DeviceId, + scopes: readonly RemoteDeviceScope[], + ): Promise { + return this.mutate((devices) => { + const record = devices.get(deviceId); + if (record === undefined) { + throw new RemoteAuthorityError("unknown_device", "Device is not locally paired"); + } + if (record.local.revokedAt !== undefined) { + throw new RemoteAuthorityError("device_revoked", "Device is revoked"); + } + const normalizedScopes = canonicalScopes(scopes); + const currentScopes = new Set(record.local.scopes); + if (normalizedScopes.some((scope) => !currentScopes.has(scope))) { + throw new RemoteAuthorityError( + "grant_conflict", + "A local grant update may only narrow current scopes", + ); + } + if (sameScopes(record.local.scopes, normalizedScopes)) return devices; + devices.set(deviceId, { + ...record, + local: { + generation: nextGeneration(record.local.generation), + scopes: normalizedScopes, + }, + }); + return devices; + }).then(() => this.requiredSnapshot(deviceId)); + } + + onDeviceRevoked(listener: (deviceId: DeviceId) => void): () => void { + this.revocationListeners.add(listener); + return () => this.revocationListeners.delete(listener); + } + + applyHostedGrant( + deviceId: DeviceId, + generation: number, + scopes: readonly RemoteDeviceScope[], + revokedAt?: number, + ): Promise { + return this.mutate((devices) => { + const record = devices.get(deviceId); + if (record === undefined) { + throw new RemoteAuthorityError("unknown_device", "Device is not locally paired"); + } + const normalizedGeneration = positiveInteger(generation, "generation"); + const normalizedScopes = canonicalScopes(scopes); + const normalizedRevokedAt = + revokedAt === undefined ? undefined : nonNegativeInteger(revokedAt, "revokedAt"); + const hosted = record.hosted; + if (hosted !== undefined && normalizedGeneration < hosted.generation) { + throw new RemoteAuthorityError( + "stale_grant_generation", + "Hosted grant generation is stale", + ); + } + if (hosted?.revokedAt !== undefined && normalizedRevokedAt === undefined) { + throw new RemoteAuthorityError( + "grant_conflict", + "Revoked device identity cannot be restored", + ); + } + if (hosted !== undefined && normalizedGeneration === hosted.generation) { + if ( + !sameScopes(hosted.scopes, normalizedScopes) || + hosted.revokedAt !== normalizedRevokedAt + ) { + throw new RemoteAuthorityError( + "grant_conflict", + "Hosted grant generation is bound to another value", + ); + } + return devices; + } + devices.set(deviceId, { + ...record, + hosted: { + generation: normalizedGeneration, + scopes: normalizedScopes, + ...(normalizedRevokedAt === undefined ? {} : { revokedAt: normalizedRevokedAt }), + }, + }); + return devices; + }).then(() => { + const snapshot = this.requiredSnapshot(deviceId); + if (snapshot.hostedRevoked) this.publishRevocation(deviceId); + return snapshot; + }); + } + + revokeLocalDevice(deviceId: DeviceId, now = Date.now()): Promise { + return this.mutate((devices) => { + const record = devices.get(deviceId); + if (record === undefined) { + throw new RemoteAuthorityError("unknown_device", "Device is not locally paired"); + } + if (record.local.revokedAt !== undefined) return devices; + devices.set(deviceId, { + ...record, + local: { + generation: nextGeneration(record.local.generation), + scopes: record.local.scopes, + revokedAt: nonNegativeInteger(now, "now"), + }, + }); + return devices; + }).then(() => { + const snapshot = this.requiredSnapshot(deviceId); + this.publishRevocation(deviceId); + return snapshot; + }); + } + + authorize(deviceId: DeviceId, requiredScope: RemoteDeviceScope): RemoteAuthorizationContext { + const record = this.devices.get(deviceId); + if (record === undefined) { + throw new RemoteAuthorityError("unknown_device", "Device is not locally paired"); + } + if (record.local.revokedAt !== undefined || record.hosted?.revokedAt !== undefined) { + throw new RemoteAuthorityError("device_revoked", "Device is revoked"); + } + if (record.hosted === undefined) { + throw new RemoteAuthorityError("hosted_grant_missing", "Hosted device grant is unavailable"); + } + const scopes = effectiveScopes(record); + if (!scopes.includes(requiredScope)) { + throw new RemoteAuthorityError("scope_forbidden", "Device does not hold the required scope"); + } + return { + installationId: this.installationId, + deviceId, + localGrantGeneration: record.local.generation, + hostedGrantGeneration: record.hosted.generation, + effectiveScopes: scopes, + }; + } + + runAuthorizedUntilAccepted( + deviceId: DeviceId, + requiredScope: RemoteDeviceScope, + start: (context: RemoteAuthorizationContext) => StartedAuthorizedOperation, + ): Promise { + const admitted = this.serialize(async () => { + const context = this.authorize(deviceId, requiredScope); + const operation = start(context); + void operation.completion.catch(() => undefined); + await operation.acceptance; + return { completion: operation.completion }; + }); + return admitted.then(({ completion }) => completion); + } + + private publishRevocation(deviceId: DeviceId): void { + for (const listener of this.revocationListeners) listener(deviceId); + } + + private requiredSnapshot(deviceId: DeviceId): RemoteDeviceAuthoritySnapshot { + const snapshot = this.snapshot(deviceId); + if (snapshot === undefined) throw new Error("Remote authority mutation lost its device record"); + return snapshot; + } + + private mutate( + operation: ( + devices: Map, + ) => Map, + ): Promise { + return this.serialize(async () => { + const candidate = new Map(this.devices); + const next = operation(candidate); + const state: PersistedAuthorityState = { + version: AUTHORITY_FORMAT_VERSION, + installationId: this.installationId, + devices: [...next.values()].sort((left, right) => + left.deviceId.localeCompare(right.deviceId), + ), + }; + await writeAtomic(this.path, state); + this.devices = next; + }); + } + + private serialize(operation: () => Promise): Promise { + const result = this.tail.then(operation); + this.tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } +} diff --git a/packages/daemon/src/remote-rpc.ts b/packages/daemon/src/remote-rpc.ts new file mode 100644 index 00000000..9a04a57e --- /dev/null +++ b/packages/daemon/src/remote-rpc.ts @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import type { RemoteDeviceScope, RpcMethod } from "@axl/protocol"; + +const REMOTE_RPC_SCOPES = Object.freeze({ + "daemon.info": "observe", + "session.list": "observe", + "session.history": "observe", + "session.ack": "observe", + "session.unsubscribe": "observe", + "session.subscribe": "observe", + "session.workspace.list": "observe", + "session.workspace.read": "observe", + "session.workspace.status": "observe", + "session.workspace.diff": "observe", + "session.send": "steer", + "session.steer": "steer", + "session.followUp": "steer", + "session.interruptAndDeliver": "steer", + "session.queue.enqueue": "steer", + "session.queue.requeue": "steer", + "session.interrupt": "steer", +} as const satisfies Partial>); + +export type RemoteRpcMethod = keyof typeof REMOTE_RPC_SCOPES; + +export function requiredRemoteScope(method: RpcMethod): RemoteDeviceScope | undefined { + return REMOTE_RPC_SCOPES[method as RemoteRpcMethod]; +} + +export function remoteRpcMethods(): readonly RemoteRpcMethod[] { + return Object.keys(REMOTE_RPC_SCOPES) as RemoteRpcMethod[]; +} diff --git a/packages/daemon/test/remote-authority.test.ts b/packages/daemon/test/remote-authority.test.ts new file mode 100644 index 00000000..80ce2188 --- /dev/null +++ b/packages/daemon/test/remote-authority.test.ts @@ -0,0 +1,401 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { mkdtemp, realpath, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test, { type TestContext } from "node:test"; + +import { type ModelPort, ToolRegistry } from "@axl/kernel"; +import { + type ModelStreamEvent, + hashCanonicalRequest, + parseDeviceId, + parseInstallationId, + parseOperationId, + parseSessionId, +} from "@axl/protocol"; + +import { DeterministicFakeRemoteCryptoAdapter } from "../../protocol/test/support/fake-remote-crypto.ts"; +import { CommandJournal, CommandJournalError } from "../src/command-journal.ts"; +import { AxlDaemon } from "../src/daemon.ts"; +import { RemoteAuthorityError, RemoteDeviceAuthorityStore } from "../src/remote-authority.ts"; +import { remoteRpcMethods, requiredRemoteScope } from "../src/remote-rpc.ts"; + +const installationId = parseInstallationId("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); +const deviceId = parseDeviceId("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); +const daemonEndpointId = parseDeviceId("cccccccc-cccc-4ccc-8ccc-cccccccccccc"); + +async function directory(): Promise { + return mkdtemp(join(tmpdir(), "axl-remote-authority-")); +} + +function replyPort(): ModelPort { + return { + stream() { + return (async function* (): AsyncGenerator { + yield { + type: "completed", + stopReason: "stop", + usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }; + })(); + }, + }; +} + +async function startDaemon( + context: TestContext, + securityMode: "sandboxed" | "unsafe" = "sandboxed", +) { + const root = await directory(); + context.after(() => rm(root, { recursive: true, force: true })); + const cwd = await realpath(root); + const dataDirectory = join(root, "data"); + const daemon = new AxlDaemon({ + socketPath: join(root, "daemon.sock"), + dataDirectory, + securityMode, + sandboxProvider: "fixture", + runtime: () => ({ model: replyPort(), tools: new ToolRegistry(), system: "test" }), + }); + await daemon.start(); + context.after(() => daemon.stop()); + return { daemon, dataDirectory, cwd }; +} + +test("remote RPC scope mapping is explicit and excludes dangerous surfaces", () => { + assert.equal(requiredRemoteScope("daemon.info"), "observe"); + assert.equal(requiredRemoteScope("session.send"), "steer"); + assert.equal(requiredRemoteScope("session.shell"), undefined); + assert.equal(requiredRemoteScope("session.interaction.respond"), undefined); + assert.equal(requiredRemoteScope("provider.auth.login"), undefined); + assert.ok(remoteRpcMethods().length > 0); +}); + +test("intersects local and hosted grants without allowing hosted widening", async () => { + const dataDirectory = await directory(); + const store = await RemoteDeviceAuthorityStore.open(dataDirectory, installationId); + + await store.registerLocalDevice(deviceId, ["steer", "observe"]); + assert.throws( + () => store.authorize(deviceId, "observe"), + (error) => error instanceof RemoteAuthorityError && error.code === "hosted_grant_missing", + ); + + const snapshot = await store.applyHostedGrant(deviceId, 1, [ + "manage_sessions", + "observe", + "steer", + ]); + assert.deepEqual(snapshot.effectiveScopes, ["observe", "steer"]); + assert.deepEqual(store.authorize(deviceId, "steer"), { + installationId, + deviceId, + localGrantGeneration: 1, + hostedGrantGeneration: 1, + effectiveScopes: ["observe", "steer"], + }); + assert.throws( + () => store.authorize(deviceId, "manage_sessions"), + (error) => error instanceof RemoteAuthorityError && error.code === "scope_forbidden", + ); + + const narrowed = await store.narrowLocalGrant(deviceId, ["observe"]); + assert.equal(narrowed.localGeneration, 2); + assert.deepEqual(narrowed.effectiveScopes, ["observe"]); + await assert.rejects( + store.narrowLocalGrant(deviceId, ["observe", "steer"]), + (error) => error instanceof RemoteAuthorityError && error.code === "grant_conflict", + ); + + assert.equal((await stat(store.path)).mode & 0o777, 0o600); +}); + +test("serializes hosted generations and rejects stale or conflicting updates", async () => { + const store = await RemoteDeviceAuthorityStore.open(await directory(), installationId); + await store.registerLocalDevice(deviceId, ["observe", "steer"]); + + const competing = await Promise.allSettled([ + store.applyHostedGrant(deviceId, 1, ["observe"]), + store.applyHostedGrant(deviceId, 1, ["steer"]), + ]); + assert.equal(competing.filter((result) => result.status === "fulfilled").length, 1); + const rejected = competing.find((result) => result.status === "rejected"); + assert.ok(rejected?.status === "rejected"); + assert.ok(rejected.reason instanceof RemoteAuthorityError); + assert.equal(rejected.reason.code, "grant_conflict"); + + await assert.rejects( + store.applyHostedGrant(deviceId, 0, ["observe"]), + /generation must be positive/, + ); + + await store.applyHostedGrant(deviceId, 2, ["observe", "steer"]); + await assert.rejects( + store.applyHostedGrant(deviceId, 1, ["observe"]), + (error) => error instanceof RemoteAuthorityError && error.code === "stale_grant_generation", + ); +}); + +test("persists irreversible revocation and rechecks it after fake E2EE authentication", async () => { + const dataDirectory = await directory(); + const store = await RemoteDeviceAuthorityStore.open(dataDirectory, installationId); + await store.registerLocalDevice(deviceId, ["observe", "steer"]); + await store.applyHostedGrant(deviceId, 1, ["observe", "steer"]); + + const deviceCrypto = new DeterministicFakeRemoteCryptoAdapter(deviceId, daemonEndpointId); + const daemonCrypto = new DeterministicFakeRemoteCryptoAdapter(daemonEndpointId, deviceId); + const envelope = await deviceCrypto.seal( + daemonEndpointId, + new TextEncoder().encode('{"method":"test.ping"}'), + ); + const authenticated = await daemonCrypto.open(envelope); + assert.equal(authenticated.authenticatedDeviceId, deviceId); + assert.equal(store.authorize(authenticated.authenticatedDeviceId, "steer").deviceId, deviceId); + + await store.revokeLocalDevice(deviceId, 1_900_000_000_000); + assert.throws( + () => store.authorize(authenticated.authenticatedDeviceId, "steer"), + (error) => error instanceof RemoteAuthorityError && error.code === "device_revoked", + ); + + const restored = await RemoteDeviceAuthorityStore.open(dataDirectory, installationId); + assert.equal(restored.snapshot(deviceId)?.locallyRevoked, true); + assert.throws( + () => restored.authorize(deviceId, "observe"), + (error) => error instanceof RemoteAuthorityError && error.code === "device_revoked", + ); + await assert.rejects( + restored.registerLocalDevice(deviceId, ["observe", "steer"]), + (error) => error instanceof RemoteAuthorityError && error.code === "grant_conflict", + ); +}); + +test("authorizes before applying durable command idempotency behind fake E2EE", async () => { + const dataDirectory = await directory(); + const store = await RemoteDeviceAuthorityStore.open(dataDirectory, installationId); + await store.registerLocalDevice(deviceId, ["observe", "steer"]); + await store.applyHostedGrant(deviceId, 1, ["observe", "steer"]); + + const deviceCrypto = new DeterministicFakeRemoteCryptoAdapter(deviceId, daemonEndpointId); + const daemonCrypto = new DeterministicFakeRemoteCryptoAdapter(daemonEndpointId, deviceId); + const sessionId = parseSessionId("dddddddd-dddd-4ddd-8ddd-dddddddddddd"); + const idempotencyKey = parseOperationId("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"); + const params = { sessionId }; + const envelope = await deviceCrypto.seal( + daemonEndpointId, + new TextEncoder().encode(JSON.stringify({ method: "session.interrupt", params })), + ); + const opened = await daemonCrypto.open(envelope); + const journal = await CommandJournal.open(dataDirectory); + let executions = 0; + const execute = () => + store.runAuthorizedUntilAccepted(opened.authenticatedDeviceId, "steer", () => + journal.start( + { + idempotencyKey, + method: "session.interrupt", + requestHash: hashCanonicalRequest("session.interrupt", params), + targetSessionId: sessionId, + }, + async () => { + executions += 1; + await new Promise((resolve) => setTimeout(resolve, 5)); + return { interrupted: false }; + }, + ), + ); + + assert.deepEqual(await Promise.all([execute(), execute()]), [ + { interrupted: false }, + { interrupted: false }, + ]); + assert.equal(executions, 1); + await assert.rejects( + store.runAuthorizedUntilAccepted(opened.authenticatedDeviceId, "steer", () => + journal.start( + { + idempotencyKey, + method: "session.interrupt", + requestHash: hashCanonicalRequest("session.interrupt", { + sessionId: parseSessionId("ffffffff-ffff-4fff-8fff-ffffffffffff"), + }), + }, + async () => ({ interrupted: false }), + ), + ), + (error) => error instanceof CommandJournalError && error.code === "idempotency_conflict", + ); +}); + +test("revocation waits for durable acceptance but not operation completion", async () => { + const dataDirectory = await directory(); + const store = await RemoteDeviceAuthorityStore.open(dataDirectory, installationId); + await store.registerLocalDevice(deviceId, ["steer"]); + await store.applyHostedGrant(deviceId, 1, ["steer"]); + const journal = await CommandJournal.open(dataDirectory); + const sessionId = parseSessionId("dddddddd-dddd-4ddd-8ddd-dddddddddddd"); + const idempotencyKey = parseOperationId("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"); + let release!: () => void; + const hold = new Promise((resolve) => { + release = resolve; + }); + let completed = false; + + const completion = store + .runAuthorizedUntilAccepted(deviceId, "steer", () => + journal.start( + { + idempotencyKey, + method: "session.interrupt", + requestHash: hashCanonicalRequest("session.interrupt", { sessionId }), + targetSessionId: sessionId, + }, + async () => { + await hold; + return { interrupted: false }; + }, + ), + ) + .finally(() => { + completed = true; + }); + await store.revokeLocalDevice(deviceId, 1_900_000_000_000); + assert.equal(completed, false); + assert.throws( + () => store.authorize(deviceId, "steer"), + (error) => error instanceof RemoteAuthorityError && error.code === "device_revoked", + ); + + release(); + assert.deepEqual(await completion, { interrupted: false }); +}); + +test("makes hosted revocation irreversible for one device identity", async () => { + const store = await RemoteDeviceAuthorityStore.open(await directory(), installationId); + await store.registerLocalDevice(deviceId, ["observe", "steer"]); + await store.applyHostedGrant(deviceId, 1, ["observe", "steer"]); + await store.applyHostedGrant(deviceId, 2, ["observe", "steer"], 1_900_000_000_000); + + assert.throws( + () => store.authorize(deviceId, "observe"), + (error) => error instanceof RemoteAuthorityError && error.code === "device_revoked", + ); + await assert.rejects( + store.applyHostedGrant(deviceId, 3, ["observe", "steer"]), + (error) => error instanceof RemoteAuthorityError && error.code === "grant_conflict", + ); +}); + +test("internal dispatcher enforces scope, method allowlist, identity, and active revocation", async (context) => { + const { daemon, dataDirectory } = await startDaemon(context); + const authority = await RemoteDeviceAuthorityStore.open(dataDirectory, installationId); + await authority.registerLocalDevice(deviceId, ["observe"]); + await authority.applyHostedGrant(deviceId, 1, ["observe", "steer"]); + const deliveries: unknown[] = []; + const attachment = daemon.attachAuthenticatedRemoteDevice({ + deviceId, + authority, + send: (message) => deliveries.push(message), + }); + + const info = await attachment.request({ + deviceId, + requestId: "11111111-1111-4111-8111-111111111111", + method: "daemon.info", + params: {}, + }); + assert.equal(info.method, "daemon.info"); + assert.deepEqual(info.result, { + securityMode: "sandboxed", + sandboxProvider: "fixture", + }); + assert.deepEqual(deliveries, []); + + await assert.rejects( + attachment.request({ + deviceId, + requestId: "22222222-2222-4222-8222-222222222222", + method: "session.interrupt", + params: { sessionId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd" }, + idempotencyKey: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + }), + (error) => error instanceof RemoteAuthorityError && error.code === "scope_forbidden", + ); + await assert.rejects( + attachment.request({ + deviceId, + requestId: "33333333-3333-4333-8333-333333333333", + method: "connection.ping", + params: {}, + }), + (error) => error instanceof RemoteAuthorityError && error.code === "remote_method_forbidden", + ); + await assert.rejects( + attachment.request({ + deviceId: "ffffffff-ffff-4fff-8fff-ffffffffffff", + requestId: "44444444-4444-4444-8444-444444444444", + method: "daemon.info", + params: {}, + }), + (error) => error instanceof RemoteAuthorityError && error.code === "device_identity_mismatch", + ); + + await authority.revokeLocalDevice(deviceId); + await assert.rejects( + attachment.request({ + deviceId, + requestId: "55555555-5555-4555-8555-555555555555", + method: "daemon.info", + params: {}, + }), + (error) => error instanceof RemoteAuthorityError && error.code === "device_revoked", + ); +}); + +test("internal dispatcher rejects remote mutations in unsafe mode", async (context) => { + const { daemon, dataDirectory } = await startDaemon(context, "unsafe"); + const authority = await RemoteDeviceAuthorityStore.open(dataDirectory, installationId); + await authority.registerLocalDevice(deviceId, ["observe", "steer"]); + await authority.applyHostedGrant(deviceId, 1, ["observe", "steer"]); + const attachment = daemon.attachAuthenticatedRemoteDevice({ + deviceId, + authority, + send: () => undefined, + }); + + await assert.rejects( + attachment.request({ + deviceId, + requestId: "66666666-6666-4666-8666-666666666666", + method: "session.interrupt", + params: { sessionId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd" }, + idempotencyKey: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + }), + (error) => error instanceof RemoteAuthorityError && error.code === "unsafe_remote_forbidden", + ); +}); + +test("rejects an oversized authority store before parsing", async () => { + const dataDirectory = await directory(); + await writeFile(join(dataDirectory, "remote-authority.json"), new Uint8Array(1024 * 1024 + 1)); + + await assert.rejects( + RemoteDeviceAuthorityStore.open(dataDirectory, installationId), + /exceeds 1048576 bytes/, + ); +}); + +test("rejects a symlinked authority store", async () => { + const dataDirectory = await directory(); + const target = join(dataDirectory, "outside.json"); + await writeFile(target, "{}\n"); + await symlink(target, join(dataDirectory, "remote-authority.json")); + + await assert.rejects( + RemoteDeviceAuthorityStore.open(dataDirectory, installationId), + /must be a regular file/, + ); +}); diff --git a/packages/protocol/src/remote-transport.ts b/packages/protocol/src/remote-transport.ts index 4090c282..4412419e 100644 --- a/packages/protocol/src/remote-transport.ts +++ b/packages/protocol/src/remote-transport.ts @@ -138,6 +138,15 @@ export interface RelayFailure { export type RelayBinaryFrame = RelaySendFrame | RelayDelivery | RelayReceipt | RelayFailure; +export const REMOTE_DEVICE_SCOPES = [ + "observe", + "steer", + "approve_within_policy", + "manage_sessions", +] as const; + +export type RemoteDeviceScope = (typeof REMOTE_DEVICE_SCOPES)[number]; + export interface AuthenticatedRemoteRequest { readonly deviceId: DeviceId; readonly requestId: RequestId; @@ -585,6 +594,24 @@ export function parseRelayRevocationResult(value: unknown): RelayRevocationResul return { version: INTERNAL_RELAY_API_VERSION, accepted: true }; } +export function parseRemoteDeviceScopes( + value: unknown, + path = "scopes", +): readonly RemoteDeviceScope[] { + if (!Array.isArray(value)) fail(path, "must be an array"); + const scopes = value.map((candidate, index) => { + if ( + typeof candidate !== "string" || + !(REMOTE_DEVICE_SCOPES as readonly string[]).includes(candidate) + ) { + fail(`${path}[${index}]`, "is not a known remote device scope"); + } + return candidate as RemoteDeviceScope; + }); + if (new Set(scopes).size !== scopes.length) fail(path, "must not contain duplicate scopes"); + return [...scopes].sort(); +} + export function parseAuthenticatedRemoteRequest(value: unknown): AuthenticatedRemoteRequest { const candidate = object(value, "request"); exact(candidate, "request", ["deviceId", "requestId", "method", "params"], ["idempotencyKey"]); diff --git a/packages/protocol/test/remote-transport.test.ts b/packages/protocol/test/remote-transport.test.ts index 51548afe..13affa61 100644 --- a/packages/protocol/test/remote-transport.test.ts +++ b/packages/protocol/test/remote-transport.test.ts @@ -20,6 +20,7 @@ import { parseRelayBinaryFrame, parseRelayDiscoveryMessage, parseRelayRevocationNotification, + parseRemoteDeviceScopes, ProtocolValidationError, RELAY_FAILURE_CODE_VALUES, REMOTE_TRANSPORT_VERSION, @@ -142,6 +143,18 @@ test("validates role-scoped route discovery messages", () => { ); }); +test("validates and canonicalizes remote device scopes", () => { + assert.deepEqual(parseRemoteDeviceScopes(["steer", "observe"]), ["observe", "steer"]); + assert.throws( + () => parseRemoteDeviceScopes(["observe", "observe"]), + (error) => error instanceof ProtocolValidationError && error.path === "scopes", + ); + assert.throws( + () => parseRemoteDeviceScopes(["unsafe"]), + (error) => error instanceof ProtocolValidationError && error.path === "scopes[0]", + ); +}); + test("keeps the deterministic fake E2EE adapter in test support", async () => { const daemonId = parseDeviceId("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); const deviceId = parseDeviceId("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); From 08700a98aa8ce7274e6b8760e5e2371fb20ae5ce Mon Sep 17 00:00:00 2001 From: Lokesh Date: Sat, 12 Sep 2026 21:17:09 +0400 Subject: [PATCH 10/16] feat(sdk): add opaque durable outbox contract Signed-off-by: Lokesh --- ROADMAP.md | 9 ++ packages/protocol/src/remote-transport.ts | 42 ++++++ packages/sdk/README.md | 2 + packages/sdk/src/index.ts | 1 + packages/sdk/src/remote-outbox.ts | 160 ++++++++++++++++++++++ packages/sdk/test/remote-outbox.test.ts | 118 ++++++++++++++++ 6 files changed, 332 insertions(+) create mode 100644 packages/sdk/src/remote-outbox.ts create mode 100644 packages/sdk/test/remote-outbox.test.ts diff --git a/ROADMAP.md b/ROADMAP.md index ebc6aec0..94baa6a2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2380,6 +2380,15 @@ The transport checkpoint was approved. The next private slice remains disabled f - [ ] Implement the reviewed permission-action events and RPC after protocol review. - [ ] Keep relay, runtime, CLI, SDK, and ordinary-session wiring disabled until their later gates. +#### Remote SDK delivery checkpoint + +- [x] Add an injected atomic durable-outbox interface for opaque encrypted requests. +- [x] Retry byte-identical opaque envelopes with new transport attempt IDs. +- [x] Keep relay admission and forwarding receipts diagnostic only. +- [x] Permit removal only after daemon acceptance. +- [x] Reset uncertain sending state to queued on reconnect without re-encryption. +- [ ] Connect the opaque outbox to a reviewed real-E2EE transactional sealing API. + #### Mobile clients - [ ] Choose mobile implementation stacks when work begins, based on concrete platform and product requirements. diff --git a/packages/protocol/src/remote-transport.ts b/packages/protocol/src/remote-transport.ts index 4412419e..c7fa745f 100644 --- a/packages/protocol/src/remote-transport.ts +++ b/packages/protocol/src/remote-transport.ts @@ -584,6 +584,48 @@ export function parseRelayRevocationNotification(value: unknown): RelayRevocatio }; } +export function parseOpaqueOutboxRecord(value: unknown): OpaqueOutboxRecord { + const candidate = object(value, "outboxRecord"); + exact(candidate, "outboxRecord", [ + "requestId", + "idempotencyKey", + "destinationRouteId", + "opaqueEnvelope", + "createdAt", + "state", + ]); + if (!(candidate.opaqueEnvelope instanceof Uint8Array)) { + fail("outboxRecord.opaqueEnvelope", "must be bytes"); + } + if ( + candidate.opaqueEnvelope.byteLength === 0 || + candidate.opaqueEnvelope.byteLength > MAX_RELAY_OPAQUE_PAYLOAD_BYTES + ) { + fail( + "outboxRecord.opaqueEnvelope", + `must contain 1 through ${MAX_RELAY_OPAQUE_PAYLOAD_BYTES} bytes`, + ); + } + if ( + candidate.state !== "queued_local" && + candidate.state !== "sending" && + candidate.state !== "daemon_accepted" + ) { + fail("outboxRecord.state", "is invalid"); + } + return { + requestId: parseRemoteRequestId(candidate.requestId, "outboxRecord.requestId"), + idempotencyKey: parseIdempotencyKey(candidate.idempotencyKey, "outboxRecord.idempotencyKey"), + destinationRouteId: parseRouteId( + candidate.destinationRouteId, + "outboxRecord.destinationRouteId", + ), + opaqueEnvelope: candidate.opaqueEnvelope.slice(), + createdAt: timestamp(candidate.createdAt, "outboxRecord.createdAt"), + state: candidate.state, + }; +} + export function parseRelayRevocationResult(value: unknown): RelayRevocationResult { const candidate = object(value, "result"); exact(candidate, "result", ["version", "accepted"]); diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 85168ed6..ffa5118d 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -1,4 +1,5 @@ + # `@axl/sdk` @@ -28,6 +29,7 @@ The SDK owns: - explicit prompt delivery outcomes across send, steer, follow-up, queue, and interrupt workflows - bounded, content-verified blob uploads with progress and cancellation - generation-checked workspace browsing, file reads, diffs, and checkpoint controls +- an injected atomic opaque-outbox store that retries exact ciphertext bytes and removes mutations only after daemon acceptance The SDK does not own: diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 21996779..3fbc40db 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -10,6 +10,7 @@ export * from "./delivery.ts"; export * from "./models.ts"; export * from "./presentation.ts"; export * from "./projector.ts"; +export * from "./remote-outbox.ts"; export * from "./subscription.ts"; export * from "./workspace.ts"; export * from "./host.ts"; diff --git a/packages/sdk/src/remote-outbox.ts b/packages/sdk/src/remote-outbox.ts new file mode 100644 index 00000000..20206bfe --- /dev/null +++ b/packages/sdk/src/remote-outbox.ts @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import { + parseOpaqueOutboxRecord, + type OpaqueOutboxRecord, + type RequestId, + type TransportAttemptId, +} from "@axl/protocol"; + +export interface OpaqueOutboxTransaction { + readonly record?: OpaqueOutboxRecord; + readonly result: Result; +} + +/** Platform stores must commit each transaction atomically and durably. */ +export interface OpaqueOutboxStore { + transact( + requestId: RequestId, + operation: (current: OpaqueOutboxRecord | undefined) => OpaqueOutboxTransaction, + ): Promise; + list(): Promise; +} + +export interface TransportAttemptIdFactory { + create(): TransportAttemptId; +} + +export interface OpaqueTransportAttempt { + readonly attemptId: TransportAttemptId; + readonly requestId: RequestId; + readonly destinationRouteId: OpaqueOutboxRecord["destinationRouteId"]; + readonly opaqueEnvelope: Uint8Array; +} + +export class OpaqueOutboxError extends Error { + readonly code: "outbox_conflict" | "unknown_request" | "not_daemon_accepted"; + + constructor(code: OpaqueOutboxError["code"], message: string) { + super(message); + this.name = "OpaqueOutboxError"; + this.code = code; + } +} + +function sameBytes(left: Uint8Array, right: Uint8Array): boolean { + return left.byteLength === right.byteLength && left.every((byte, index) => byte === right[index]); +} + +function sameRecord(left: OpaqueOutboxRecord, right: OpaqueOutboxRecord): boolean { + return ( + left.requestId === right.requestId && + left.idempotencyKey === right.idempotencyKey && + left.destinationRouteId === right.destinationRouteId && + left.createdAt === right.createdAt && + sameBytes(left.opaqueEnvelope, right.opaqueEnvelope) + ); +} + +/** Reliable opaque-byte delivery state. It never encrypts or re-encrypts a request. */ +export class OpaqueOutbox { + private readonly store: OpaqueOutboxStore; + private readonly attemptIds: TransportAttemptIdFactory; + + constructor(store: OpaqueOutboxStore, attemptIds: TransportAttemptIdFactory) { + this.store = store; + this.attemptIds = attemptIds; + } + + enqueue(value: OpaqueOutboxRecord): Promise { + const record = parseOpaqueOutboxRecord(value); + if (record.state !== "queued_local") { + throw new OpaqueOutboxError("outbox_conflict", "A new outbox record must be queued locally"); + } + return this.store.transact(record.requestId, (current) => { + if (current !== undefined && !sameRecord(current, record)) { + throw new OpaqueOutboxError( + "outbox_conflict", + "Request ID is already bound to different opaque bytes or metadata", + ); + } + return { record: current ?? record, result: undefined }; + }); + } + + beginAttempt(requestId: RequestId): Promise { + return this.store.transact(requestId, (current) => { + if (current === undefined) { + throw new OpaqueOutboxError("unknown_request", "Outbox request does not exist"); + } + if (current.state === "daemon_accepted") { + throw new OpaqueOutboxError("outbox_conflict", "Accepted request must not be resent"); + } + const record = parseOpaqueOutboxRecord({ ...current, state: "sending" }); + return { + record, + result: { + attemptId: this.attemptIds.create(), + requestId: record.requestId, + destinationRouteId: record.destinationRouteId, + opaqueEnvelope: record.opaqueEnvelope.slice(), + }, + }; + }); + } + + markDaemonAccepted(requestId: RequestId): Promise { + return this.store.transact(requestId, (current) => { + if (current === undefined) { + throw new OpaqueOutboxError("unknown_request", "Outbox request does not exist"); + } + return { + record: parseOpaqueOutboxRecord({ ...current, state: "daemon_accepted" }), + result: undefined, + }; + }); + } + + removeAccepted(requestId: RequestId): Promise { + return this.store.transact(requestId, (current) => { + if (current === undefined) return { result: undefined }; + if (current.state !== "daemon_accepted") { + throw new OpaqueOutboxError( + "not_daemon_accepted", + "Only a daemon-accepted request may leave the outbox", + ); + } + return { result: undefined }; + }); + } + + resetSendingAfterDisconnect(): Promise { + return this.store.list().then(async (records) => { + for (const record of records) { + if (record.state !== "sending") continue; + await this.store.transact(record.requestId, (current) => { + if (current === undefined) return { result: undefined }; + if (current.state !== "sending") return { record: current, result: undefined }; + return { + record: parseOpaqueOutboxRecord({ ...current, state: "queued_local" }), + result: undefined, + }; + }); + } + }); + } + + list(): Promise { + return this.store + .list() + .then((records) => + records + .map((record) => parseOpaqueOutboxRecord(record)) + .sort( + (left, right) => + left.createdAt - right.createdAt || left.requestId.localeCompare(right.requestId), + ), + ); + } +} diff --git a/packages/sdk/test/remote-outbox.test.ts b/packages/sdk/test/remote-outbox.test.ts new file mode 100644 index 00000000..80814992 --- /dev/null +++ b/packages/sdk/test/remote-outbox.test.ts @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseIdempotencyKey, + parseRemoteRequestId, + parseRouteId, + parseTransportAttemptId, + type OpaqueOutboxRecord, + type RequestId, +} from "@axl/protocol"; + +import { + OpaqueOutbox, + OpaqueOutboxError, + type OpaqueOutboxStore, + type OpaqueOutboxTransaction, +} from "../src/remote-outbox.ts"; + +class MemoryOutboxStore implements OpaqueOutboxStore { + private readonly records = new Map(); + private tail: Promise = Promise.resolve(); + + transact( + requestId: RequestId, + operation: (current: OpaqueOutboxRecord | undefined) => OpaqueOutboxTransaction, + ): Promise { + const result = this.tail.then(() => { + const transaction = operation(this.records.get(requestId)); + if (transaction.record === undefined) this.records.delete(requestId); + else this.records.set(requestId, transaction.record); + return transaction.result; + }); + this.tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + async list(): Promise { + await this.tail; + return [...this.records.values()]; + } +} + +const requestId = parseRemoteRequestId("11111111-1111-4111-8111-111111111111"); +const idempotencyKey = parseIdempotencyKey("22222222-2222-4222-8222-222222222222"); +const destinationRouteId = parseRouteId("33333333-3333-4333-8333-333333333333"); + +function record(bytes = Uint8Array.of(0, 1, 2, 255)): OpaqueOutboxRecord { + return { + requestId, + idempotencyKey, + destinationRouteId, + opaqueEnvelope: bytes, + createdAt: 1_900_000_000_000, + state: "queued_local", + }; +} + +function outbox(): OpaqueOutbox { + let attempt = 0; + return new OpaqueOutbox(new MemoryOutboxStore(), { + create() { + attempt += 1; + return parseTransportAttemptId( + `44444444-4444-4444-8444-${attempt.toString().padStart(12, "0")}`, + ); + }, + }); +} + +test("retries exact opaque bytes under new transport attempt IDs", async () => { + const queue = outbox(); + await queue.enqueue(record()); + + const first = await queue.beginAttempt(requestId); + const second = await queue.beginAttempt(requestId); + assert.notEqual(first.attemptId, second.attemptId); + assert.deepEqual(first.opaqueEnvelope, Uint8Array.of(0, 1, 2, 255)); + assert.deepEqual(second.opaqueEnvelope, first.opaqueEnvelope); + assert.equal((await queue.list())[0]?.state, "sending"); + + await queue.resetSendingAfterDisconnect(); + assert.equal((await queue.list())[0]?.state, "queued_local"); +}); + +test("rejects conflicting request IDs and removal before daemon acceptance", async () => { + const queue = outbox(); + await queue.enqueue(record()); + await queue.enqueue(record()); + await assert.rejects( + queue.enqueue(record(Uint8Array.of(9))), + (error) => error instanceof OpaqueOutboxError && error.code === "outbox_conflict", + ); + await assert.rejects( + queue.removeAccepted(requestId), + (error) => error instanceof OpaqueOutboxError && error.code === "not_daemon_accepted", + ); +}); + +test("removes a mutation only after daemon acceptance", async () => { + const queue = outbox(); + await queue.enqueue(record()); + await queue.beginAttempt(requestId); + + await queue.markDaemonAccepted(requestId); + assert.equal((await queue.list())[0]?.state, "daemon_accepted"); + await assert.rejects(queue.beginAttempt(requestId), /must not be resent/); + + await queue.removeAccepted(requestId); + assert.deepEqual(await queue.list(), []); + await queue.removeAccepted(requestId); +}); From 36814de8181aa768e40d0cb277b806577d296472 Mon Sep 17 00:00:00 2001 From: Lokesh Date: Mon, 14 Sep 2026 17:12:47 +0400 Subject: [PATCH 11/16] feat(remote): complete fake-E2EE hosted path Signed-off-by: Lokesh --- .github/workflows/ci.yml | 24 +- CODE_STRUCTURE.md | 6 +- ROADMAP.md | 15 +- docs/architecture/e2ee-transport-preflight.md | 8 +- docs/architecture/remote-daemon-authority.md | 4 +- docs/architecture/remote-hosted-path.md | 77 ++ .../daemon/test/remote-hosted-path.test.ts | 578 ++++++++++++ packages/protocol/README.md | 2 +- packages/protocol/src/remote-transport.ts | 129 ++- .../protocol/test/remote-transport.test.ts | 61 ++ packages/sdk/README.md | 6 +- packages/sdk/src/index.ts | 1 + packages/sdk/src/remote-outbox.ts | 46 +- packages/sdk/src/remote-relay.ts | 854 ++++++++++++++++++ packages/sdk/test/remote-outbox.test.ts | 39 +- packages/sdk/test/remote-relay.test.ts | 348 +++++++ services/relay/README.md | 2 +- .../axl_relay/http_control_plane_client.ex | 10 +- .../relay/test/support/hosted_path_server.exs | 36 + 19 files changed, 2201 insertions(+), 45 deletions(-) create mode 100644 docs/architecture/remote-hosted-path.md create mode 100644 packages/daemon/test/remote-hosted-path.test.ts create mode 100644 packages/sdk/src/remote-relay.ts create mode 100644 packages/sdk/test/remote-relay.test.ts create mode 100644 services/relay/test/support/hosted_path_server.exs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6da928c1..6ac9a8bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ name: CI on: pull_request: - branches: [main, "release/**"] + branches: [main, RC, "release/**"] merge_group: types: [checks_requested] push: @@ -51,6 +51,11 @@ jobs: - '.github/workflows/**' relay: - 'services/relay/**' + - 'services/control-plane/**' + - 'packages/daemon/src/remote-*.ts' + - 'packages/daemon/test/remote-*.test.ts' + - 'packages/sdk/src/remote-*.ts' + - 'packages/sdk/test/remote-*.test.ts' - 'packages/protocol/src/remote-transport.ts' - 'packages/protocol/test/fixtures/remote-transport-v1.json' - 'packages/protocol/test/fixtures/internal-relay-api-v1.json' @@ -94,6 +99,18 @@ jobs: run: echo "No relay changes detected; required check reports success." - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 if: needs.changes.outputs.relay == 'true' + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + if: needs.changes.outputs.relay == 'true' + with: + standalone: true + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + if: needs.changes.outputs.relay == 'true' + with: + node-version: 24 + cache: pnpm + - name: Install Node dependencies + if: needs.changes.outputs.relay == 'true' + run: pnpm install --frozen-lockfile --ignore-scripts - name: Set up Erlang and Elixir if: needs.changes.outputs.relay == 'true' uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 @@ -117,6 +134,11 @@ jobs: if: needs.changes.outputs.relay == 'true' working-directory: services/relay run: mix format --check-formatted && mix compile --warnings-as-errors && mix test && mix credo --strict && mix dialyzer && mix deps.audit + - name: Run disposable hosted-path integration + if: needs.changes.outputs.relay == 'true' + env: + AXL_RUN_HOSTED_PATH_INTEGRATION: '1' + run: pnpm --filter @axl/daemon test licenses: name: REUSE licenses diff --git a/CODE_STRUCTURE.md b/CODE_STRUCTURE.md index 3f88723e..108c5772 100644 --- a/CODE_STRUCTURE.md +++ b/CODE_STRUCTURE.md @@ -7,7 +7,7 @@ Status: working plan. This document accompanies [ROADMAP.md](ROADMAP.md) and [OPEN_SOURCE.md](OPEN_SOURCE.md). -Updated: 2026-09-12 +Updated: 2026-09-13 ## 1. Keep everything in one repository @@ -71,7 +71,7 @@ These rules keep package ownership clear: - First-party extensions use the same public extension API as third-party extensions. - `packages/protocol` is the only source of wire-format truth. TypeScript definitions stay authoritative until a non-TypeScript presentation client creates a real need for generation. The Elixir relay implements only its narrow transport and internal-service framing against canonical byte and JSON fixtures; it is not a daemon-protocol client. - Apps use the public protocol SDK rather than package internals. -- `services/control-plane` may depend on `packages/protocol`. It owns hosted account, installation, device, ticket, prekey, grant, upload-reservation, quota, and security-audit mutation. Identity providers, persistent datastores, and production service authentication stay behind injected interfaces until approved. +- `services/control-plane` may depend on `packages/protocol`. It owns hosted account, installation, device, ticket, opaque KeyPackage and Welcome rendezvous, grant, upload-reservation, quota, and security-audit mutation. Identity providers, persistent datastores, and production service authentication stay behind injected interfaces until approved. - `services/relay` consumes versioned language-neutral fixtures. It must not import TypeScript package internals, access the control-plane datastore, decrypt envelopes, interpret daemon RPC, persist canonical history, or store attachment bodies. It calls the authenticated control-plane admission API once per new connection and accepts authenticated revocation notifications. - The control plane and relay are separate deployables. They share no private implementation imports and communicate only through their versioned internal HTTP contract. - `packages/runtime` assembles providers, tools, extensions, sandboxing, and the authoritative daemon without importing a presentation client. @@ -118,7 +118,7 @@ Every required check reports a result. Path filters decide whether the full job - Kernel, protocol, and SDK changes run all builds, including both mobile apps. - Control-plane changes run the root TypeScript checks and package-boundary checks. -- Relay or shared remote-fixture changes run Mix formatting, compilation with warnings as errors, tests, Credo, Dialyzer, dependency audit, cross-language fixture checks, package-boundary checks, and REUSE. +- Relay, control-plane, or shared remote-transport changes run Mix formatting, compilation with warnings as errors, tests, Credo, Dialyzer, dependency audit, the disposable cross-runtime hosted-path test, package-boundary checks, and REUSE. - App-only changes run that app and lint checks. - Documentation and plan changes run formatting, link checking, and REUSE checks. - CodeQL, Gitleaks, and dependency review run for every merge candidate. diff --git a/ROADMAP.md b/ROADMAP.md index 94baa6a2..c914e9a8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1361,7 +1361,7 @@ Requirements: The current mobile plan favors SwiftUI on iOS and Jetpack Compose on Android because native code supports Live Activities, Android foreground services, notification actions, widgets, share sheets, and efficient streaming text. This is not a binding stack decision. Choose the implementation when mobile work begins and its requirements are concrete. -Remote transport uses pairwise application-level E2EE in addition to TLS. The approved direction is PQXDH for asynchronous session establishment and Triple Ratchet for ongoing messages. This direction supersedes any earlier Noise selection. Production cryptography remains blocked on Person 1's security RFC, exact suite, reviewed library, secure-state design, interoperability fixtures, and independent security review. Transport code treats encrypted envelopes and public prekey bundles as bounded opaque bytes. The relay never imports the E2EE implementation or decrypts traffic. The proposed remote action-binding and approval rules are in [`docs/architecture/remote-permission-authorization.md`](docs/architecture/remote-permission-authorization.md); that draft does not enable remote approval. +Remote transport uses pairwise application-level E2EE in addition to TLS. The provisional direction is OpenMLS with one daemon-device group per relationship, opaque KeyPackage and Welcome rendezvous, daemon-only commits, phone Update proposals, and explicit draft-suite migration. Production cryptography remains blocked on Person 1's security RFC, exact suite, reviewed library, browser/WASM feasibility, secure-state transaction, interoperability fixtures, and independent security review. Transport code treats prepared envelopes and rendezvous objects as bounded opaque bytes. The relay never imports the E2EE implementation or decrypts traffic. The proposed remote action-binding and approval rules are in [`docs/architecture/remote-permission-authorization.md`](docs/architecture/remote-permission-authorization.md); that draft does not enable remote approval. The managed path uses two separately deployable services: the TypeScript control plane owns hosted state and one-use admission, while the Elixir/OTP relay owns bounded in-memory WebSocket routing. The daemon remains the command and session authority. Transport proof uses only disposable sessions, a deterministic fake provider, opaque fixtures, and a test-only fake E2EE adapter. Ordinary-session steering and remote permission approval remain disabled until the E2EE and release gates pass. @@ -2358,13 +2358,13 @@ The private slice was created from clean `main` commit `ea906d0295ba67f833c49ace #### Remote transport preflight -- [x] Record PQXDH plus Triple Ratchet as the approved direction and keep exact production cryptography blocked on Person 1's reviewed contract and library. +- [x] Replace the obsolete PQXDH and Triple Ratchet direction with the provisional pairwise OpenMLS profile while keeping exact production cryptography blocked on Person 1's reviewed contract and library. - [x] Add the separately deployable TypeScript control plane under `services/control-plane/` with authenticated ticket issuance and atomic one-use consumption through injected interfaces. - [x] Add the separately deployable Elixir/OTP relay under `services/relay/` with authenticated admission, opaque bounded framing, in-memory installation-scoped routing, backpressure, heartbeat, lease, revocation, and draining behavior. - [x] Publish language-neutral admission, revocation, and exact binary accept/reject fixtures consumed by both implementations. - [x] Run TypeScript and Mix formatting, compilation, tests, static analysis, dependency auditing, package-boundary, and SPDX/REUSE checks in CI. - [x] Draft the daemon-owned remote permission action-binding contract without enabling it. -- [x] Stop at the architecture checkpoint before daemon, SDK, prekey, attachment, or production integration work. +- [x] Stop at the architecture checkpoint before daemon, SDK, cryptographic rendezvous, attachment, or production integration work. #### Remote daemon authority checkpoint @@ -2383,11 +2383,16 @@ The transport checkpoint was approved. The next private slice remains disabled f #### Remote SDK delivery checkpoint - [x] Add an injected atomic durable-outbox interface for opaque encrypted requests. +- [x] Persist a stable crypto-session destination and resolve ephemeral relay routes for each attempt. - [x] Retry byte-identical opaque envelopes with new transport attempt IDs. +- [x] Acquire one-use tickets and perform bounded first-frame WebSocket admission. +- [x] Track route snapshots, replacements, daemon availability, and bounded reconnect. - [x] Keep relay admission and forwarding receipts diagnostic only. -- [x] Permit removal only after daemon acceptance. +- [x] Permit removal only after authenticated daemon acceptance. - [x] Reset uncertain sending state to queued on reconnect without re-encryption. -- [ ] Connect the opaque outbox to a reviewed real-E2EE transactional sealing API. +- [x] Prove the real control plane, relay, daemon authority, SDK, cursor resume, restart, duplicate, revocation, and overflow boundaries in one disposable fake-E2EE test. +- [ ] Connect the opaque outbox to Person 1's reviewed atomic OpenMLS prepared-envelope transaction. +- [ ] Implement the reviewed bounded authority-audit sink described in [`docs/architecture/remote-hosted-path.md`](docs/architecture/remote-hosted-path.md). #### Mobile clients diff --git a/docs/architecture/e2ee-transport-preflight.md b/docs/architecture/e2ee-transport-preflight.md index 37c33ff8..e455207f 100644 --- a/docs/architecture/e2ee-transport-preflight.md +++ b/docs/architecture/e2ee-transport-preflight.md @@ -22,13 +22,13 @@ Allowed work is limited to: - bounded routing, queues, heartbeat, lease expiry, revocation, draining, and rate limits - later daemon authorization and SDK delivery tests behind a test-only fake E2EE adapter -Person 1 exclusively owns PQXDH, Triple Ratchet, pairing cryptography, signatures, cryptographic prekey validation and consumption, cryptographic replay behavior, secure key and ratchet storage, encryption and decryption, associated data, attachment cryptography, and cryptographic test vectors. +Person 1 exclusively owns the OpenMLS profile, pairwise group lifecycle, pairing cryptography, signatures, KeyPackage and Welcome validation and consumption, cryptographic replay behavior, secure epoch-state storage, encryption and decryption, associated data, attachment cryptography, and cryptographic test vectors. -PQXDH plus Triple Ratchet is the approved direction and supersedes earlier Noise selections. No production cryptography may be implemented or enabled until Person 1 supplies an approved RFC, exact suite, reviewed library, secure-state contract, and interoperability fixtures and the integrated result passes independent review. +The provisional direction is OpenMLS with one pairwise daemon-device group, daemon-only commits, phone Update proposals, an opaque KeyPackage and Welcome rendezvous, and explicit draft-suite migration. No production cryptography may be implemented or enabled until Person 1 supplies an approved RFC, exact suite, reviewed library, browser/WASM feasibility, secure-state transaction, and interoperability fixtures and the integrated result passes independent review. This transport document defines no OpenMLS wire fields or persistence format. ## Service ownership -`services/control-plane` is the only hosted component allowed to mutate account, installation, device, ticket, prekey, grant, upload-reservation, quota, and security-audit state. This slice implements ticket state only. Authentication, authorization, proof verification, clocks, and persistence are injected. Test adapters are deterministic and are not production defaults. +`services/control-plane` is the only hosted component allowed to mutate account, installation, device, ticket, opaque KeyPackage and Welcome rendezvous, grant, upload-reservation, quota, and security-audit state. This slice implements ticket state only. Authentication, authorization, proof verification, clocks, and persistence are injected. Test adapters are deterministic and are not production defaults. `services/relay` owns ticket-authenticated WebSocket admission and bounded in-memory routing. It has no database access, E2EE dependency, RPC knowledge, canonical history, durable mailbox, or attachment storage. The relay derives the source route from consumed-ticket state and never accepts it from a sender. @@ -151,4 +151,4 @@ The architecture review selected role-filtered relay discovery, strict opposite- ## Review boundary -Stop here after the documentation, CI boundaries, fixtures, ticket-consumption path, and first bounded relay slice pass. Daemon authorization, SDK outbox behavior, prekey storage, S3 transport, real E2EE integration, ordinary-session steering, and permission approvals require the next reviewed milestone. +This preflight checkpoint was followed by daemon authority, SDK delivery, and a disposable hosted-path test behind fake E2EE. See [`remote-hosted-path.md`](remote-hosted-path.md). OpenMLS rendezvous storage, S3 transport, real E2EE integration, ordinary-session steering, and permission approvals remain separate reviewed milestones. diff --git a/docs/architecture/remote-daemon-authority.md b/docs/architecture/remote-daemon-authority.md index 60776849..4b540a2f 100644 --- a/docs/architecture/remote-daemon-authority.md +++ b/docs/architecture/remote-daemon-authority.md @@ -7,7 +7,7 @@ Status: approved infrastructure behind test-only fake E2EE ## Scope -This slice establishes durable installation-scoped device authority without enabling a network remote transport in the daemon. `packages/daemon/src/remote-authority.ts` owns the local record and effective grant calculation. An internal authenticated attachment connects an explicitly allowlisted subset of existing RPCs to the same daemon dispatcher and command journal. The relay and control plane cannot widen daemon authority. +This slice establishes durable installation-scoped device authority without enabling production network access in the daemon. `packages/daemon/src/remote-authority.ts` owns the local record and effective grant calculation. An internal authenticated attachment connects an explicitly allowlisted subset of existing RPCs to the same daemon dispatcher and command journal. The relay and control plane cannot widen daemon authority. A disposable hosted-path test now wires this attachment to the real relay through test-only fake E2EE; no production runtime starts that bridge. The processing contract remains: @@ -66,7 +66,7 @@ Retryable mutations enter the existing daemon command journal while the authorit ## Current non-capabilities -This module is not wired to the relay, runtime, CLI, SDK, or ordinary sessions. It does not: +This module is not wired to the production runtime, CLI, or ordinary sessions. Outside the explicit disposable integration test, it does not: - authenticate cryptography - define pairing or key storage diff --git a/docs/architecture/remote-hosted-path.md b/docs/architecture/remote-hosted-path.md new file mode 100644 index 00000000..f5852a7c --- /dev/null +++ b/docs/architecture/remote-hosted-path.md @@ -0,0 +1,77 @@ + + + +# Remote hosted-path checkpoint + +Status: test-only fake-E2EE integration + +## Scope + +This checkpoint connects the real TypeScript control plane, Elixir relay, daemon remote-authority boundary, and TypeScript SDK in one disposable test. It proves transport and authorization behavior. It does not enable ordinary-session remote access or provide production cryptography, identity, storage, service credentials, deployment, or permission approval. + +The deterministic fake E2EE adapter remains under protocol test support. Production source consumes only opaque prepared envelopes and an injected authenticated opener. + +## Stable destination and ephemeral routes + +A durable outbox record stores a stable opaque crypto-session identifier. It never stores a relay route. The SDK resolves the currently advertised daemon route immediately before each transport attempt. A reconnect therefore changes the transport attempt ID and route while retaining byte-identical prepared ciphertext, request ID, and daemon idempotency key. + +The crypto-session identifier is an integration seam, not an OpenMLS state format. Person 1 owns the final destination identity contract and the transaction that advances cryptographic state and inserts immutable ciphertext. The current `OpaqueOutboxStore` remains fake-E2EE scaffolding and must not be treated as the production OpenMLS transaction. + +## SDK delivery boundary + +The SDK now provides: + +- authenticated HTTP ticket acquisition with a bounded proof interface +- a bounded binary first-frame WebSocket admission +- route snapshot, availability, unavailability, and replacement handling +- bounded exponential reconnect with jitter +- per-attempt route resolution +- relay admitted and forwarded diagnostics +- opaque inbound delivery through an injected authenticated opener +- authenticated daemon acceptance, result, error, and ordinary server-delivery messages +- durable outbox removal only after matching authenticated daemon acceptance +- ephemeral prepared sends for read requests such as subscription resume + +The SDK does not encrypt, decrypt, advance epochs, create prepared records, or claim command authority. Relay receipts never remove durable mutations. + +## Disposable topology + +The explicit hosted-path integration test starts: + +1. an in-process real control-plane HTTP server with deterministic injected identity, authorization, proof, clock, and ticket storage, +2. a separately running real Elixir relay using its HTTP control-plane client, +3. a real sandboxed daemon with durable remote authority and command journal, +4. daemon and device relay WebSocket connections, +5. test-only fake E2EE endpoints, and +6. the real SDK outbox and delivery coordinator. + +It verifies ticket issuance and consumption, route discovery, fake authenticated opening, daemon authorization, durable command acceptance, response delivery, relay restart, changed-route retry with byte-identical ciphertext, cursor-based subscription resume, daemon restart, duplicate idempotency, revocation, and oversized-payload rejection. The test is opt-in outside the relay CI job because it requires the pinned Elixir toolchain. + +## Provisional cryptographic direction + +The provisional endpoint direction is OpenMLS with one pairwise group for each daemon-device relationship. Pairing uses an opaque KeyPackage and Welcome rendezvous owned by the control plane. The daemon is the only committer. A phone may submit Update proposals but does not commit group state. Draft suite versions are explicit and migrations create a new versioned session rather than silently reinterpreting persisted state. + +This document defines no OpenMLS fields, algorithms, validation rules, storage representation, or transaction implementation. Person 1 must supply those details, browser/WASM feasibility, interoperability fixtures, and an independently reviewed prepared-envelope transaction before real E2EE integration. + +## Authority audit gate + +The authority store does not yet emit a complete security-audit stream. Before real E2EE or ordinary-session access is enabled, a reviewed daemon-owned audit sink must durably record bounded events for: + +- local device registration +- local scope narrowing +- hosted grant installation or narrowing +- local and hosted revocation +- failed authorization by stable reason code + +Audit records may contain installation/device identifiers, generations, scope names, timestamps, and reason codes. They must not contain credentials, relay tickets, possession proofs, ciphertext, key material, plaintext request bodies, prompts, or sensitive parameters. Audit persistence and authority mutation ordering must be defined before implementation; this checkpoint does not add a non-atomic best-effort sink. + +## Remaining gates + +Before production remote control: + +- Person 1 must provide the reviewed OpenMLS prepared-envelope transaction and browser/WASM persistence strategy. +- MLS application, Update, commit, and epoch-ready delivery classes need an ordered priority contract. +- Production identity, datastore, workload authentication, quotas, deployment, and TLS termination must be selected. +- The authority audit gate above must be implemented. +- Permission lifecycle, action-digest binding, policy generations, and race resolution must be implemented and reviewed. +- Ordinary-session remote exposure must receive an explicit enablement review. diff --git a/packages/daemon/test/remote-hosted-path.test.ts b/packages/daemon/test/remote-hosted-path.test.ts new file mode 100644 index 00000000..7f085836 --- /dev/null +++ b/packages/daemon/test/remote-hosted-path.test.ts @@ -0,0 +1,578 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { createServer } from "node:http"; +import { mkdtemp, readFile, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import test, { type TestContext } from "node:test"; + +import { type ModelPort, ToolRegistry } from "@axl/kernel"; +import { + MAX_RELAY_OPAQUE_PAYLOAD_BYTES, + REMOTE_TRANSPORT_VERSION, + encodeRemoteDaemonMessage, + parseAuthenticatedRemoteRequest, + parseCryptoSessionId, + parseDeviceId, + parseIdempotencyKey, + parseInstallationId, + parseRemoteRequestId, + parseSessionId, + parseTransportAttemptId, + type AuthenticatedRemoteRequest, + type ModelStreamEvent, + type OpaqueOutboxRecord, + type RemoteDaemonMessage, + type RequestId, + type RouteId, +} from "@axl/protocol"; +import { + HttpRelayTicketProvider, + OpaqueOutbox, + RemoteHostedDelivery, + RemoteRelayConnection, + type OpaqueOutboxStore, + type OpaqueOutboxTransaction, + type RemoteWebSocket, + type RemoteWebSocketFactory, + type TransportAttemptIdFactory, +} from "@axl/sdk"; +import { DeterministicFakeRemoteCryptoAdapter } from "../../protocol/test/support/fake-remote-crypto.ts"; +import { + InMemoryRelayTicketStore, + RelayTicketService, + createControlPlaneHandler, +} from "../../../services/control-plane/src/index.ts"; +import { AxlDaemon, type AuthenticatedRemoteAttachment } from "../src/daemon.ts"; +import { RemoteDeviceAuthorityStore } from "../src/remote-authority.ts"; + +const repositoryRoot = dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url))))); +const installationId = parseInstallationId("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); +const deviceId = parseDeviceId("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); +const daemonId = parseDeviceId("cccccccc-cccc-4ccc-8ccc-cccccccccccc"); +const cryptoSessionId = parseCryptoSessionId("dddddddd-dddd-4ddd-8ddd-dddddddddddd"); +const requestId = parseRemoteRequestId("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"); +const idempotencyKey = parseIdempotencyKey("ffffffff-ffff-4fff-8fff-ffffffffffff"); + +class MemoryOutboxStore implements OpaqueOutboxStore { + private readonly records = new Map(); + + async transact( + id: RequestId, + operation: (current: OpaqueOutboxRecord | undefined) => OpaqueOutboxTransaction, + ): Promise { + const transaction = operation(this.records.get(id)); + if (transaction.record === undefined) this.records.delete(id); + else this.records.set(id, transaction.record); + return transaction.result; + } + + async list(): Promise { + return [...this.records.values()]; + } +} + +class LoopbackWebSocketFactory implements RemoteWebSocketFactory { + private readonly url: string; + + constructor(url: string) { + this.url = url; + } + + connect(): RemoteWebSocket { + const Constructor = (globalThis as unknown as { WebSocket: new (url: string) => unknown }) + .WebSocket; + return new Constructor(this.url) as RemoteWebSocket; + } +} + +function replyPort(): ModelPort { + return { + stream() { + return (async function* (): AsyncGenerator { + yield { + type: "completed", + stopReason: "stop", + usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }; + })(); + }, + }; +} + +async function freePort(): Promise { + const server = createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + assert.ok(address !== null && typeof address !== "string"); + const port = address.port; + await new Promise((resolve, reject) => + server.close((error) => (error === undefined ? resolve() : reject(error))), + ); + return port; +} + +async function waitFor( + description: string, + predicate: () => boolean, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${description}`); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + +async function startRelay(port: number, controlPlaneOrigin: string): Promise { + const child = spawn("mix", ["run", "--no-halt", "test/support/hosted_path_server.exs"], { + cwd: join(repositoryRoot, "services/relay"), + env: { + ...process.env, + MIX_ENV: "test", + AXL_RELAY_TEST_PORT: String(port), + AXL_CONTROL_PLANE_TEST_ORIGIN: controlPlaneOrigin, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout?.on("data", (chunk: Uint8Array) => { + output += Buffer.from(chunk).toString("utf8"); + }); + child.stderr?.on("data", (chunk: Uint8Array) => { + output += Buffer.from(chunk).toString("utf8"); + }); + await Promise.race([ + waitFor("relay startup", () => output.includes("AXL_RELAY_TEST_READY"), 30_000), + once(child, "exit").then(([code]) => { + throw new Error(`Relay exited during startup with ${String(code)}: ${output}`); + }), + ]); + return child; +} + +async function stopRelay(child: ChildProcess): Promise { + if (child.exitCode !== null) return; + child.kill("SIGTERM"); + await Promise.race([ + once(child, "exit").then(() => undefined), + new Promise((resolve) => setTimeout(resolve, 5_000)), + ]); + if (child.exitCode === null) child.kill("SIGKILL"); +} + +function attempts(prefix: string): TransportAttemptIdFactory { + let counter = 0; + return { + create() { + counter += 1; + return parseTransportAttemptId( + `${prefix.slice(0, 24)}-${counter.toString().padStart(12, "0")}`, + ); + }, + }; +} + +async function sealRequest( + crypto: DeterministicFakeRemoteCryptoAdapter, + request: AuthenticatedRemoteRequest, +): Promise { + return crypto.seal(daemonId, new TextEncoder().encode(JSON.stringify(request))); +} + +const runHostedIntegration = + process.env.AXL_RUN_HOSTED_PATH_INTEGRATION === "1" && + spawnSync("mix", ["--version"], { + cwd: join(repositoryRoot, "services/relay"), + stdio: "ignore", + }).status === 0; + +test( + "real hosted path retries exact fake ciphertext through new routes with one durable effect", + { + skip: runHostedIntegration ? false : "set AXL_RUN_HOSTED_PATH_INTEGRATION=1 with Mix installed", + }, + async (context: TestContext) => { + const root = await mkdtemp(join(tmpdir(), "axl-hosted-path-")); + context.after(() => rm(root, { recursive: true, force: true })); + const cwd = await realpath(root); + const dataDirectory = join(root, "daemon-data"); + const socketPath = join(root, "daemon.sock"); + const relayPort = await freePort(); + let hostedGeneration: number | undefined = 1; + let tokenCounter = 0; + let routeCounter = 0; + + const tickets = new RelayTicketService({ + store: new InMemoryRelayTicketStore(), + authorizer: { + async currentGeneration(principal, requested) { + if ( + principal.accountId !== "account-fixture" || + requested.installationId !== installationId + ) { + return undefined; + } + if (requested.role === "device" && requested.deviceId !== deviceId) return undefined; + return hostedGeneration; + }, + }, + proofVerifier: { + async verify(_ticket, requested) { + return Buffer.from(requested.possessionProof).equals(Buffer.from([0, 1, 2, 3, 255])); + }, + }, + relayUrl: "wss://relay.invalid/v1/connect", + randomToken: () => { + tokenCounter += 1; + return `hosted-path-ticket-${tokenCounter}`; + }, + randomId: () => { + routeCounter += 1; + return `11111111-1111-4111-8111-${routeCounter.toString().padStart(12, "0")}`; + }, + }); + const controlPlane = createServer( + createControlPlaneHandler({ + tickets, + publicAuthentication: { + async authenticate(request) { + return request.headers.authorization === "Bearer public-fixture" + ? { accountId: "account-fixture" } + : undefined; + }, + }, + internalAuthentication: { + async authenticate(request) { + return request.headers.authorization === "Bearer internal-fixture"; + }, + }, + }), + ); + controlPlane.listen(0, "127.0.0.1"); + await once(controlPlane, "listening"); + context.after( + () => + new Promise((resolve) => { + controlPlane.closeAllConnections(); + controlPlane.close(() => resolve()); + }), + ); + const controlAddress = controlPlane.address(); + assert.ok(controlAddress !== null && typeof controlAddress !== "string"); + const controlOrigin = `http://127.0.0.1:${controlAddress.port}`; + + let relay = await startRelay(relayPort, controlOrigin); + context.after(() => stopRelay(relay)); + const sockets = new LoopbackWebSocketFactory(`ws://127.0.0.1:${relayPort}/v1/connect`); + const proof = { + async create() { + return { + connectionNonce: "hosted-path-nonce", + possessionProof: Uint8Array.of(0, 1, 2, 3, 255), + }; + }, + }; + const ticketProvider = (role: "daemon" | "device") => + new HttpRelayTicketProvider({ + controlPlaneOrigin: controlOrigin, + request: { + installationId, + role, + ...(role === "device" ? { deviceId } : {}), + }, + authenticationHeaders: async () => ({ authorization: "Bearer public-fixture" }), + proof, + allowInsecureLoopbackForTests: true, + }); + const connectionOptions = { + sockets, + reconnect: { + maximumAttempts: 20, + initialDelayMs: 50, + maximumDelayMs: 200, + jitterRatio: 0, + }, + routeWaitMs: 5_000, + } as const; + const daemonConnection = new RemoteRelayConnection({ + ...connectionOptions, + tickets: ticketProvider("daemon"), + }); + const deviceConnection = new RemoteRelayConnection({ + ...connectionOptions, + tickets: ticketProvider("device"), + destinationCryptoSessionId: cryptoSessionId, + }); + + let daemon = new AxlDaemon({ + socketPath, + dataDirectory, + securityMode: "sandboxed", + sandboxProvider: "fixture", + runtime: () => ({ model: replyPort(), tools: new ToolRegistry(), system: "test" }), + }); + await daemon.start(); + context.after(() => daemon.stop()); + let authority = await RemoteDeviceAuthorityStore.open(dataDirectory, installationId); + await authority.registerLocalDevice(deviceId, ["observe", "steer"]); + await authority.applyHostedGrant(deviceId, 1, ["observe", "steer"]); + const created = await daemon.sessions.create(cwd); + const sessionId = parseSessionId(created.sessionId); + let attachment: AuthenticatedRemoteAttachment = daemon.attachAuthenticatedRemoteDevice({ + deviceId, + authority, + send: () => undefined, + }); + + const deviceCrypto = new DeterministicFakeRemoteCryptoAdapter(deviceId, daemonId); + const daemonCrypto = new DeterministicFakeRemoteCryptoAdapter(daemonId, deviceId); + const daemonAttempts = attempts("22222222-2222-4222-8222"); + const deviceAttempts = attempts("33333333-3333-4333-8333"); + const receivedCiphertexts: Uint8Array[] = []; + const receivedRoutes: RouteId[] = []; + const relayDiagnostics: string[] = []; + deviceConnection.onReceipt((receipt) => + relayDiagnostics.push(`receipt:${receipt.status}:${receipt.attemptId}`), + ); + deviceConnection.onFailure((failure) => + relayDiagnostics.push(`failure:${failure.code}:${failure.attemptId}`), + ); + daemonConnection.onFailure((failure) => + relayDiagnostics.push(`daemon-failure:${failure.code}:${failure.attemptId}`), + ); + let dropNextResponse = false; + let mutationDeliveries = 0; + const bridgeErrors: Error[] = []; + + const sendDaemonMessage = async ( + destinationRoute: RouteId, + message: RemoteDaemonMessage, + ): Promise => { + const ciphertext = await daemonCrypto.seal(deviceId, encodeRemoteDaemonMessage(message)); + daemonConnection.send(destinationRoute, daemonAttempts.create(), ciphertext); + }; + + daemonConnection.onDelivery((delivery) => { + void (async () => { + const opened = await daemonCrypto.open(delivery.opaquePayload); + const request = parseAuthenticatedRemoteRequest( + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(opened.plaintext)), + ); + const isMutation = request.requestId === requestId; + if (isMutation) { + receivedCiphertexts.push(delivery.opaquePayload.slice()); + receivedRoutes.push(delivery.sourceRouteId); + } + const response = await attachment.request(request); + if (isMutation) mutationDeliveries += 1; + if (dropNextResponse) { + dropNextResponse = false; + return; + } + if (request.idempotencyKey !== undefined) { + await sendDaemonMessage(delivery.sourceRouteId, { + version: REMOTE_TRANSPORT_VERSION, + type: "daemon_accepted", + requestId: request.requestId, + idempotencyKey: request.idempotencyKey, + }); + } + await sendDaemonMessage(delivery.sourceRouteId, { + version: REMOTE_TRANSPORT_VERSION, + type: "daemon_result", + requestId: response.requestId, + method: response.method, + result: response.result, + }); + })().catch((error: unknown) => { + bridgeErrors.push(error instanceof Error ? error : new Error(String(error))); + }); + }); + + await daemonConnection.start(); + const store = new MemoryOutboxStore(); + const outbox = new OpaqueOutbox(store, deviceAttempts, deviceConnection); + const delivery = new RemoteHostedDelivery({ + connection: deviceConnection, + outbox, + attemptIds: deviceAttempts, + expectedDaemonId: daemonId, + opener: { + async open(ciphertext) { + const opened = await deviceCrypto.open(ciphertext); + return { authenticatedPeerId: opened.authenticatedDeviceId, plaintext: opened.plaintext }; + }, + }, + }); + const messages: RemoteDaemonMessage[] = []; + const deliveryErrors: Error[] = []; + delivery.onMessage((message) => messages.push(message)); + delivery.onError((error) => deliveryErrors.push(error)); + await delivery.start(); + + const sendEphemeralRequest = async (request: AuthenticatedRemoteRequest): Promise => { + await delivery.sendPreparedEphemeral( + cryptoSessionId, + await sealRequest(deviceCrypto, request), + ); + }; + const waitForResult = async (id: RequestId, count = 1) => { + await waitFor( + `daemon result ${id}`, + () => + messages.filter((message) => message.type === "daemon_result" && message.requestId === id) + .length >= count, + ); + const result = messages.findLast( + (message) => message.type === "daemon_result" && message.requestId === id, + ); + assert.ok(result?.type === "daemon_result"); + return result.result; + }; + + const subscribeId = parseRemoteRequestId("44444444-4444-4444-8444-444444444444"); + await sendEphemeralRequest({ + deviceId, + requestId: subscribeId, + method: "session.subscribe", + params: { sessionId }, + }); + const initialSubscription = (await waitForResult(subscribeId)) as { + readonly subscriptionId: string; + readonly snapshot?: { readonly boundaryCursor: string }; + }; + const cursor = initialSubscription.snapshot?.boundaryCursor; + assert.ok(cursor); + const ackId = parseRemoteRequestId("55555555-5555-4555-8555-555555555555"); + await sendEphemeralRequest({ + deviceId, + requestId: ackId, + method: "session.ack", + params: { subscriptionId: initialSubscription.subscriptionId, cursor }, + }); + await waitForResult(ackId); + + const mutation: AuthenticatedRemoteRequest = { + deviceId, + requestId, + idempotencyKey, + method: "session.interrupt", + params: { sessionId }, + }; + const preparedCiphertext = await sealRequest(deviceCrypto, mutation); + const preparedRecord: OpaqueOutboxRecord = { + requestId, + idempotencyKey, + destinationCryptoSessionId: cryptoSessionId, + opaqueEnvelope: preparedCiphertext, + createdAt: Date.now(), + state: "queued_local", + }; + dropNextResponse = true; + await delivery.enqueuePrepared(preparedRecord); + try { + await waitFor("first durable daemon execution", () => mutationDeliveries === 1); + } catch (cause) { + throw new Error( + `Hosted request did not reach the daemon; diagnostics=${JSON.stringify(relayDiagnostics)} bridgeErrors=${bridgeErrors.map((error) => error.message).join("|")}`, + { cause }, + ); + } + + await stopRelay(relay); + relay = await startRelay(relayPort, controlOrigin); + await waitFor("retry through restarted relay", () => mutationDeliveries >= 2, 20_000); + await waitForResult(requestId); + assert.deepEqual(receivedCiphertexts[1], receivedCiphertexts[0]); + assert.notEqual(receivedRoutes[1], receivedRoutes[0]); + + const resumeId = parseRemoteRequestId("66666666-6666-4666-8666-666666666666"); + attachment.close(); + attachment = daemon.attachAuthenticatedRemoteDevice({ + deviceId, + authority, + send: () => undefined, + }); + await sendEphemeralRequest({ + deviceId, + requestId: resumeId, + method: "session.subscribe", + params: { sessionId, after: cursor }, + }); + const resumed = (await waitForResult(resumeId)) as { readonly resumedFrom?: string }; + assert.equal(resumed.resumedFrom, cursor); + + await daemon.stop(); + daemon = new AxlDaemon({ + socketPath, + dataDirectory, + securityMode: "sandboxed", + sandboxProvider: "fixture", + runtime: () => ({ model: replyPort(), tools: new ToolRegistry(), system: "test" }), + }); + await daemon.start(); + authority = await RemoteDeviceAuthorityStore.open(dataDirectory, installationId); + attachment = daemon.attachAuthenticatedRemoteDevice({ + deviceId, + authority, + send: () => undefined, + }); + await delivery.enqueuePrepared(preparedRecord); + await waitFor("duplicate after daemon restart", () => mutationDeliveries >= 3); + await waitForResult(requestId, 2); + + const journal = (await readFile(join(dataDirectory, "commands.jsonl"), "utf8")) + .trim() + .split("\n") + .map( + (line) => JSON.parse(line) as { readonly type: string; readonly idempotencyKey: string }, + ); + assert.equal( + journal.filter( + (record) => record.type === "accepted" && record.idempotencyKey === idempotencyKey, + ).length, + 1, + ); + + const daemonRoute = await deviceConnection.resolve(cryptoSessionId); + assert.throws(() => + deviceConnection.send( + daemonRoute, + deviceAttempts.create(), + new Uint8Array(MAX_RELAY_OPAQUE_PAYLOAD_BYTES + 1), + ), + ); + + hostedGeneration = undefined; + await authority.applyHostedGrant(deviceId, 2, ["observe", "steer"], Date.now()); + const revocation = await fetch(`http://127.0.0.1:${relayPort}/internal/v1/revocations`, { + method: "POST", + headers: { authorization: "Bearer internal-fixture", "content-type": "application/json" }, + body: JSON.stringify({ + version: 1, + installationId, + deviceId, + generation: 2, + effectiveAt: Date.now(), + }), + }); + assert.equal(revocation.status, 200); + await waitFor( + "revoked device disconnect", + () => deviceConnection.state === "disconnected", + 20_000, + ); + + assert.deepEqual(bridgeErrors, []); + assert.deepEqual(deliveryErrors, []); + delivery.close(); + daemonConnection.close(); + }, +); diff --git a/packages/protocol/README.md b/packages/protocol/README.md index 2c8f890b..d1383650 100644 --- a/packages/protocol/README.md +++ b/packages/protocol/README.md @@ -4,4 +4,4 @@ # `@axl/protocol` -This dependency-free package defines Axl's versioned JSONL events, model stream messages, local wire protocol, and opaque remote-transport framing. The current local wire format covers session creation, listing, paged history, resume, fork, clone, rename, deletion, import, export, catalog invalidation, subscriptions, turns, steering, follow-ups, interruption, reload, live activity, abortable blob transport, workspace review, extension interactions, and model, thinking, and web-tool configuration. Remote transport contracts define routing identifiers, limits, tickets, receipts, delivery states, and bounded binary frames without defining or implementing cryptography. Runtime parsers validate every value received from an untrusted boundary. +This dependency-free package defines Axl's versioned JSONL events, model stream messages, local wire protocol, and opaque remote-transport framing. The current local wire format covers session creation, listing, paged history, resume, fork, clone, rename, deletion, import, export, catalog invalidation, subscriptions, turns, steering, follow-ups, interruption, reload, live activity, abortable blob transport, workspace review, extension interactions, and model, thinking, and web-tool configuration. Remote transport contracts define stable crypto-session destinations, ephemeral routing identifiers, limits, tickets, receipts, authenticated daemon-message shapes, delivery states, and bounded binary frames without defining or implementing cryptography. Runtime parsers validate every value received from an untrusted boundary. diff --git a/packages/protocol/src/remote-transport.ts b/packages/protocol/src/remote-transport.ts index c7fa745f..12f533ee 100644 --- a/packages/protocol/src/remote-transport.ts +++ b/packages/protocol/src/remote-transport.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { ProtocolValidationError } from "./event-envelope.ts"; +import { parseServerMessage, type ServerMessage } from "./wire.ts"; const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; const base64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; @@ -168,12 +169,41 @@ export type RemoteDeliveryState = export interface OpaqueOutboxRecord { readonly requestId: RequestId; readonly idempotencyKey: IdempotencyKey; - readonly destinationRouteId: RouteId; + /** Stable crypto-session identity. Ephemeral relay routes must never be persisted here. */ + readonly destinationCryptoSessionId: CryptoSessionId; readonly opaqueEnvelope: Uint8Array; readonly createdAt: number; readonly state: "queued_local" | "sending" | "daemon_accepted"; } +export type RemoteDaemonMessage = + | { + readonly version: typeof REMOTE_TRANSPORT_VERSION; + readonly type: "daemon_accepted"; + readonly requestId: RequestId; + readonly idempotencyKey: IdempotencyKey; + } + | { + readonly version: typeof REMOTE_TRANSPORT_VERSION; + readonly type: "daemon_result"; + readonly requestId: RequestId; + readonly method: string; + readonly result: unknown; + } + | { + readonly version: typeof REMOTE_TRANSPORT_VERSION; + readonly type: "daemon_error"; + readonly requestId: RequestId; + readonly code: string; + readonly message: string; + readonly retryable: boolean; + } + | { + readonly version: typeof REMOTE_TRANSPORT_VERSION; + readonly type: "daemon_delivery"; + readonly message: ServerMessage; + }; + export interface RelayPeerRoute { readonly routeId: RouteId; readonly role: "daemon" | "device"; @@ -589,7 +619,7 @@ export function parseOpaqueOutboxRecord(value: unknown): OpaqueOutboxRecord { exact(candidate, "outboxRecord", [ "requestId", "idempotencyKey", - "destinationRouteId", + "destinationCryptoSessionId", "opaqueEnvelope", "createdAt", "state", @@ -616,9 +646,9 @@ export function parseOpaqueOutboxRecord(value: unknown): OpaqueOutboxRecord { return { requestId: parseRemoteRequestId(candidate.requestId, "outboxRecord.requestId"), idempotencyKey: parseIdempotencyKey(candidate.idempotencyKey, "outboxRecord.idempotencyKey"), - destinationRouteId: parseRouteId( - candidate.destinationRouteId, - "outboxRecord.destinationRouteId", + destinationCryptoSessionId: parseCryptoSessionId( + candidate.destinationCryptoSessionId, + "outboxRecord.destinationCryptoSessionId", ), opaqueEnvelope: candidate.opaqueEnvelope.slice(), createdAt: timestamp(candidate.createdAt, "outboxRecord.createdAt"), @@ -626,6 +656,95 @@ export function parseOpaqueOutboxRecord(value: unknown): OpaqueOutboxRecord { }; } +export function parseRemoteDaemonMessage(value: unknown): RemoteDaemonMessage { + const candidate = object(value, "remoteDaemonMessage"); + if (candidate.version !== REMOTE_TRANSPORT_VERSION) { + fail("remoteDaemonMessage.version", `must equal ${REMOTE_TRANSPORT_VERSION}`); + } + if (candidate.type === "daemon_accepted") { + exact(candidate, "remoteDaemonMessage", ["version", "type", "requestId", "idempotencyKey"]); + return { + version: REMOTE_TRANSPORT_VERSION, + type: "daemon_accepted", + requestId: parseRemoteRequestId(candidate.requestId, "remoteDaemonMessage.requestId"), + idempotencyKey: parseIdempotencyKey( + candidate.idempotencyKey, + "remoteDaemonMessage.idempotencyKey", + ), + }; + } + if (candidate.type === "daemon_result") { + exact(candidate, "remoteDaemonMessage", ["version", "type", "requestId", "method", "result"]); + const method = boundedString(candidate.method, "remoteDaemonMessage.method", 128); + if (!methodPattern.test(method)) + fail("remoteDaemonMessage.method", "has an invalid method name"); + return { + version: REMOTE_TRANSPORT_VERSION, + type: "daemon_result", + requestId: parseRemoteRequestId(candidate.requestId, "remoteDaemonMessage.requestId"), + method, + result: candidate.result, + }; + } + if (candidate.type === "daemon_error") { + exact(candidate, "remoteDaemonMessage", [ + "version", + "type", + "requestId", + "code", + "message", + "retryable", + ]); + if (typeof candidate.retryable !== "boolean") { + fail("remoteDaemonMessage.retryable", "must be boolean"); + } + return { + version: REMOTE_TRANSPORT_VERSION, + type: "daemon_error", + requestId: parseRemoteRequestId(candidate.requestId, "remoteDaemonMessage.requestId"), + code: boundedString(candidate.code, "remoteDaemonMessage.code", 128), + message: boundedString(candidate.message, "remoteDaemonMessage.message", 1_024), + retryable: candidate.retryable, + }; + } + if (candidate.type === "daemon_delivery") { + exact(candidate, "remoteDaemonMessage", ["version", "type", "message"]); + return { + version: REMOTE_TRANSPORT_VERSION, + type: "daemon_delivery", + message: parseServerMessage(candidate.message), + }; + } + return fail("remoteDaemonMessage.type", "is invalid"); +} + +export function encodeRemoteDaemonMessage(message: RemoteDaemonMessage): Uint8Array { + const validated = parseRemoteDaemonMessage(message); + const encoded = new TextEncoder().encode(JSON.stringify(validated)); + if (encoded.byteLength > MAX_RELAY_OPAQUE_PAYLOAD_BYTES) { + fail( + "remoteDaemonMessage", + `must encode to no more than ${MAX_RELAY_OPAQUE_PAYLOAD_BYTES} bytes`, + ); + } + return encoded; +} + +export function decodeRemoteDaemonMessage(value: Uint8Array): RemoteDaemonMessage { + if (!(value instanceof Uint8Array)) fail("remoteDaemonMessage", "must be bytes"); + if (value.byteLength === 0 || value.byteLength > MAX_RELAY_OPAQUE_PAYLOAD_BYTES) { + fail("remoteDaemonMessage", `must contain 1 through ${MAX_RELAY_OPAQUE_PAYLOAD_BYTES} bytes`); + } + try { + return parseRemoteDaemonMessage( + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(value)), + ); + } catch (error) { + if (error instanceof ProtocolValidationError) throw error; + fail("remoteDaemonMessage", "must be valid UTF-8 JSON"); + } +} + export function parseRelayRevocationResult(value: unknown): RelayRevocationResult { const candidate = object(value, "result"); exact(candidate, "result", ["version", "accepted"]); diff --git a/packages/protocol/test/remote-transport.test.ts b/packages/protocol/test/remote-transport.test.ts index 13affa61..ff958c73 100644 --- a/packages/protocol/test/remote-transport.test.ts +++ b/packages/protocol/test/remote-transport.test.ts @@ -7,20 +7,25 @@ import test from "node:test"; import { decodeBase64, + decodeRemoteDaemonMessage, DEFAULT_RELAY_LIMITS, encodeBase64, + encodeRemoteDaemonMessage, encodeInternalConsumeRelayTicketRequest, encodeRelayBinaryFrame, MAX_RELAY_FRAME_BYTES, MAX_RELAY_OPAQUE_PAYLOAD_BYTES, parseInternalConsumeRelayTicketRequest, parseDeviceId, + parseIdempotencyKey, parseInternalConsumeRelayTicketResult, parseIssueRelayTicketRequest, + parseOpaqueOutboxRecord, parseRelayBinaryFrame, parseRelayDiscoveryMessage, parseRelayRevocationNotification, parseRemoteDeviceScopes, + parseRemoteRequestId, ProtocolValidationError, RELAY_FAILURE_CODE_VALUES, REMOTE_TRANSPORT_VERSION, @@ -143,6 +148,62 @@ test("validates role-scoped route discovery messages", () => { ); }); +test("keeps durable outbox destinations stable and relay routes ephemeral", () => { + const opaqueEnvelope = Uint8Array.of(1, 2, 3); + assert.deepEqual( + parseOpaqueOutboxRecord({ + requestId: "11111111-1111-4111-8111-111111111111", + idempotencyKey: "22222222-2222-4222-8222-222222222222", + destinationCryptoSessionId: "33333333-3333-4333-8333-333333333333", + opaqueEnvelope, + createdAt: 1_900_000_000_000, + state: "queued_local", + }), + { + requestId: "11111111-1111-4111-8111-111111111111", + idempotencyKey: "22222222-2222-4222-8222-222222222222", + destinationCryptoSessionId: "33333333-3333-4333-8333-333333333333", + opaqueEnvelope, + createdAt: 1_900_000_000_000, + state: "queued_local", + }, + ); + assert.throws( + () => + parseOpaqueOutboxRecord({ + requestId: "11111111-1111-4111-8111-111111111111", + idempotencyKey: "22222222-2222-4222-8222-222222222222", + destinationRouteId: "33333333-3333-4333-8333-333333333333", + opaqueEnvelope, + createdAt: 1_900_000_000_000, + state: "queued_local", + }), + (error) => + error instanceof ProtocolValidationError && error.path === "outboxRecord.destinationRouteId", + ); +}); + +test("validates daemon acceptance only inside the authenticated payload", () => { + const message = { + version: REMOTE_TRANSPORT_VERSION, + type: "daemon_accepted" as const, + requestId: parseRemoteRequestId("11111111-1111-4111-8111-111111111111"), + idempotencyKey: parseIdempotencyKey("22222222-2222-4222-8222-222222222222"), + }; + assert.deepEqual(decodeRemoteDaemonMessage(encodeRemoteDaemonMessage(message)), message); + assert.throws( + () => + decodeRemoteDaemonMessage( + new TextEncoder().encode( + JSON.stringify({ ...message, idempotencyKey: "not-an-idempotency-key" }), + ), + ), + (error) => + error instanceof ProtocolValidationError && + error.path === "remoteDaemonMessage.idempotencyKey", + ); +}); + test("validates and canonicalizes remote device scopes", () => { assert.deepEqual(parseRemoteDeviceScopes(["steer", "observe"]), ["observe", "steer"]); assert.throws( diff --git a/packages/sdk/README.md b/packages/sdk/README.md index ffa5118d..de9a603a 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -29,7 +29,9 @@ The SDK owns: - explicit prompt delivery outcomes across send, steer, follow-up, queue, and interrupt workflows - bounded, content-verified blob uploads with progress and cancellation - generation-checked workspace browsing, file reads, diffs, and checkpoint controls -- an injected atomic opaque-outbox store that retries exact ciphertext bytes and removes mutations only after daemon acceptance +- an injected atomic opaque-outbox store that persists stable crypto-session destinations, resolves ephemeral routes per attempt, retries exact ciphertext bytes, and removes mutations only after authenticated daemon acceptance +- one-use relay-ticket acquisition, bounded WebSocket admission, role-filtered route discovery, and bounded reconnect +- relay receipt diagnostics and opaque inbound delivery through an injected authenticated opener The SDK does not own: @@ -40,7 +42,7 @@ The SDK does not own: - provider-specific authentication - terminal, browser, desktop, or mobile presentation -Those responsibilities remain in the daemon, kernel, runtime, provider, and client packages. +Those responsibilities remain in the daemon, kernel, runtime, provider, and client packages. The remote delivery API consumes immutable prepared envelopes. It does not create ciphertext or commit cryptographic state; the current outbox transaction is fake-E2EE scaffolding until Person 1 supplies the reviewed atomic OpenMLS prepared-envelope contract. ## Public entry points diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 3fbc40db..c7ca37ae 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -11,6 +11,7 @@ export * from "./models.ts"; export * from "./presentation.ts"; export * from "./projector.ts"; export * from "./remote-outbox.ts"; +export * from "./remote-relay.ts"; export * from "./subscription.ts"; export * from "./workspace.ts"; export * from "./host.ts"; diff --git a/packages/sdk/src/remote-outbox.ts b/packages/sdk/src/remote-outbox.ts index 20206bfe..40c27ceb 100644 --- a/packages/sdk/src/remote-outbox.ts +++ b/packages/sdk/src/remote-outbox.ts @@ -3,8 +3,10 @@ import { parseOpaqueOutboxRecord, + type CryptoSessionId, type OpaqueOutboxRecord, type RequestId, + type RouteId, type TransportAttemptId, } from "@axl/protocol"; @@ -26,10 +28,14 @@ export interface TransportAttemptIdFactory { create(): TransportAttemptId; } +export interface OpaqueRouteResolver { + resolve(destinationCryptoSessionId: CryptoSessionId): Promise; +} + export interface OpaqueTransportAttempt { readonly attemptId: TransportAttemptId; readonly requestId: RequestId; - readonly destinationRouteId: OpaqueOutboxRecord["destinationRouteId"]; + readonly destinationRouteId: RouteId; readonly opaqueEnvelope: Uint8Array; } @@ -51,7 +57,7 @@ function sameRecord(left: OpaqueOutboxRecord, right: OpaqueOutboxRecord): boolea return ( left.requestId === right.requestId && left.idempotencyKey === right.idempotencyKey && - left.destinationRouteId === right.destinationRouteId && + left.destinationCryptoSessionId === right.destinationCryptoSessionId && left.createdAt === right.createdAt && sameBytes(left.opaqueEnvelope, right.opaqueEnvelope) ); @@ -61,10 +67,16 @@ function sameRecord(left: OpaqueOutboxRecord, right: OpaqueOutboxRecord): boolea export class OpaqueOutbox { private readonly store: OpaqueOutboxStore; private readonly attemptIds: TransportAttemptIdFactory; + private readonly routes: OpaqueRouteResolver; - constructor(store: OpaqueOutboxStore, attemptIds: TransportAttemptIdFactory) { + constructor( + store: OpaqueOutboxStore, + attemptIds: TransportAttemptIdFactory, + routes: OpaqueRouteResolver, + ) { this.store = store; this.attemptIds = attemptIds; + this.routes = routes; } enqueue(value: OpaqueOutboxRecord): Promise { @@ -83,8 +95,8 @@ export class OpaqueOutbox { }); } - beginAttempt(requestId: RequestId): Promise { - return this.store.transact(requestId, (current) => { + async beginAttempt(requestId: RequestId): Promise { + const prepared = await this.store.transact(requestId, (current) => { if (current === undefined) { throw new OpaqueOutboxError("unknown_request", "Outbox request does not exist"); } @@ -97,11 +109,33 @@ export class OpaqueOutbox { result: { attemptId: this.attemptIds.create(), requestId: record.requestId, - destinationRouteId: record.destinationRouteId, + destinationCryptoSessionId: record.destinationCryptoSessionId, opaqueEnvelope: record.opaqueEnvelope.slice(), }, }; }); + const destinationRouteId = await this.routes.resolve(prepared.destinationCryptoSessionId); + return { + attemptId: prepared.attemptId, + requestId: prepared.requestId, + destinationRouteId, + opaqueEnvelope: prepared.opaqueEnvelope, + }; + } + + markQueued(requestId: RequestId): Promise { + return this.store.transact(requestId, (current) => { + if (current === undefined) { + throw new OpaqueOutboxError("unknown_request", "Outbox request does not exist"); + } + if (current.state === "daemon_accepted") { + return { record: current, result: undefined }; + } + return { + record: parseOpaqueOutboxRecord({ ...current, state: "queued_local" }), + result: undefined, + }; + }); } markDaemonAccepted(requestId: RequestId): Promise { diff --git a/packages/sdk/src/remote-relay.ts b/packages/sdk/src/remote-relay.ts new file mode 100644 index 00000000..ac4078ab --- /dev/null +++ b/packages/sdk/src/remote-relay.ts @@ -0,0 +1,854 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import { + REMOTE_TRANSPORT_VERSION, + decodeRemoteDaemonMessage, + encodeBase64, + encodeRelayBinaryFrame, + parseDeviceId, + parseIssueRelayTicketRequest, + parseIssueRelayTicketResult, + parseRelayBinaryFrame, + parseRelayDiscoveryMessage, + type CryptoSessionId, + type DeviceId, + type IssueRelayTicketRequest, + type IssueRelayTicketResult, + type OpaqueOutboxRecord, + type RelayDelivery, + type RelayFailure, + type RelayPeerRoute, + type RelayReceipt, + type RemoteDaemonMessage, + type RemoteDeliveryState, + type RequestId, + type RouteId, + type TransportAttemptId, +} from "@axl/protocol"; + +import type { OpaqueOutbox, TransportAttemptIdFactory } from "./remote-outbox.ts"; + +const MAX_ADMISSION_BYTES = 4_096; +const DEFAULT_ROUTE_WAIT_MS = 10_000; + +export interface RelayAdmissionCredential extends IssueRelayTicketResult { + readonly connectionNonce: string; + readonly possessionProof: Uint8Array; +} + +export interface RelayTicketProvider { + acquire(): Promise; +} + +export interface RelayPossessionProofProvider { + create( + ticket: IssueRelayTicketResult, + ): Promise<{ readonly connectionNonce: string; readonly possessionProof: Uint8Array }>; +} + +export interface RemoteFetchResponse { + readonly ok: boolean; + readonly status: number; + json(): Promise; +} + +export type RemoteFetch = ( + input: string, + init: { + readonly method: "POST"; + readonly headers: Readonly>; + readonly body: string; + }, +) => Promise; + +export interface HttpRelayTicketProviderOptions { + readonly controlPlaneOrigin: string; + readonly request: IssueRelayTicketRequest; + readonly authenticationHeaders: () => Promise>>; + readonly proof: RelayPossessionProofProvider; + readonly fetch?: RemoteFetch; + /** Test-only escape hatch. Production control-plane traffic must use HTTPS. */ + readonly allowInsecureLoopbackForTests?: boolean; +} + +function validatedControlPlaneOrigin(options: HttpRelayTicketProviderOptions): string { + const url = new URL(options.controlPlaneOrigin); + if (url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") { + throw new TypeError("Control-plane origin must not contain credentials, query, or fragment"); + } + const loopback = url.hostname === "127.0.0.1" || url.hostname === "[::1]"; + if (url.protocol !== "https:" && !(options.allowInsecureLoopbackForTests === true && loopback)) { + throw new TypeError("Control-plane origin must use HTTPS"); + } + return url.origin; +} + +/** Acquires one-use relay admission without placing credentials in a URL. */ +export class HttpRelayTicketProvider implements RelayTicketProvider { + private readonly options: HttpRelayTicketProviderOptions; + private readonly origin: string; + private readonly request: RemoteFetch; + private readonly ticketRequest: IssueRelayTicketRequest; + + constructor(options: HttpRelayTicketProviderOptions) { + this.options = options; + this.ticketRequest = parseIssueRelayTicketRequest(options.request); + this.origin = validatedControlPlaneOrigin(options); + const fetcher = options.fetch ?? (globalThis as { fetch?: RemoteFetch }).fetch; + if (fetcher === undefined) throw new TypeError("A fetch implementation is required"); + this.request = fetcher; + } + + async acquire(): Promise { + const authentication = await this.options.authenticationHeaders(); + const response = await this.request(`${this.origin}/v1/relay/tickets`, { + method: "POST", + headers: { ...authentication, "content-type": "application/json" }, + body: JSON.stringify(this.ticketRequest), + }); + if (!response.ok) { + throw new RemoteRelayError( + "ticket_unavailable", + `Relay ticket request failed with HTTP ${response.status}`, + ); + } + const ticket = parseIssueRelayTicketResult(await response.json()); + const proof = await this.options.proof.create(ticket); + const nonceBytes = new TextEncoder().encode(proof.connectionNonce).byteLength; + if ( + nonceBytes === 0 || + nonceBytes > 256 || + !(proof.possessionProof instanceof Uint8Array) || + proof.possessionProof.byteLength === 0 || + proof.possessionProof.byteLength > 1_024 + ) { + throw new RemoteRelayError("invalid_admission", "Possession proof is outside relay bounds"); + } + return { ...ticket, ...proof }; + } +} + +interface RemoteWebSocketOpenEvent { + readonly type: "open"; +} + +interface RemoteWebSocketMessageEvent { + readonly type: "message"; + readonly data: unknown; +} + +interface RemoteWebSocketCloseEvent { + readonly type: "close"; + readonly code?: number; + readonly reason?: string; +} + +interface RemoteWebSocketErrorEvent { + readonly type: "error"; +} + +export type RemoteWebSocketEvent = + | RemoteWebSocketOpenEvent + | RemoteWebSocketMessageEvent + | RemoteWebSocketCloseEvent + | RemoteWebSocketErrorEvent; + +type RemoteWebSocketListener = (event: RemoteWebSocketEvent) => void; + +export interface RemoteWebSocket { + binaryType: string; + readonly readyState: number; + send(data: Uint8Array): void; + close(code?: number, reason?: string): void; + addEventListener(type: RemoteWebSocketEvent["type"], listener: RemoteWebSocketListener): void; + removeEventListener(type: RemoteWebSocketEvent["type"], listener: RemoteWebSocketListener): void; +} + +export interface RemoteWebSocketFactory { + connect(url: string): RemoteWebSocket; +} + +export class GlobalRemoteWebSocketFactory implements RemoteWebSocketFactory { + connect(url: string): RemoteWebSocket { + const Constructor = (globalThis as { WebSocket?: new (url: string) => RemoteWebSocket }) + .WebSocket; + if (Constructor === undefined) throw new TypeError("A WebSocket implementation is required"); + return new Constructor(url); + } +} + +export type RemoteRelayConnectionState = + | "disconnected" + | "connecting" + | "connected" + | "reconnecting" + | "closed"; + +export interface RemoteReconnectPolicy { + readonly maximumAttempts: number; + readonly initialDelayMs: number; + readonly maximumDelayMs: number; + readonly jitterRatio: number; +} + +const DEFAULT_RECONNECT_POLICY: RemoteReconnectPolicy = Object.freeze({ + maximumAttempts: 8, + initialDelayMs: 250, + maximumDelayMs: 10_000, + jitterRatio: 0.2, +}); + +export interface RemoteRelayConnectionOptions { + readonly tickets: RelayTicketProvider; + readonly sockets?: RemoteWebSocketFactory; + readonly destinationCryptoSessionId?: CryptoSessionId; + readonly reconnect?: Partial; + readonly routeWaitMs?: number; + readonly random?: () => number; + readonly sleep?: (milliseconds: number) => Promise; +} + +export type RemoteRelayErrorCode = + | "ticket_unavailable" + | "invalid_admission" + | "connection_failed" + | "connection_closed" + | "daemon_offline" + | "wrong_destination" + | "bad_relay_message"; + +export class RemoteRelayError extends Error { + readonly code: RemoteRelayErrorCode; + + constructor( + code: RemoteRelayErrorCode, + message: string, + options: { readonly cause?: unknown } = {}, + ) { + super(message, options); + this.name = "RemoteRelayError"; + this.code = code; + } +} + +function positiveInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${name} must be positive`); + return value; +} + +function reconnectPolicy(value: Partial = {}): RemoteReconnectPolicy { + const policy = { ...DEFAULT_RECONNECT_POLICY, ...value }; + positiveInteger(policy.maximumAttempts, "maximumAttempts"); + positiveInteger(policy.initialDelayMs, "initialDelayMs"); + positiveInteger(policy.maximumDelayMs, "maximumDelayMs"); + if (policy.maximumDelayMs < policy.initialDelayMs) { + throw new TypeError("maximumDelayMs must cover initialDelayMs"); + } + if (!Number.isFinite(policy.jitterRatio) || policy.jitterRatio < 0 || policy.jitterRatio > 1) { + throw new TypeError("jitterRatio must be from zero through one"); + } + return policy; +} + +async function messageBytes(value: unknown): Promise { + if (value instanceof Uint8Array) return value; + if (value instanceof ArrayBuffer) return new Uint8Array(value); + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength).slice(); + } + if ( + typeof value === "object" && + value !== null && + "arrayBuffer" in value && + typeof value.arrayBuffer === "function" + ) { + const buffer = await (value as { arrayBuffer(): Promise }).arrayBuffer(); + return new Uint8Array(buffer); + } + if (typeof value === "string") return new TextEncoder().encode(value); + throw new RemoteRelayError("bad_relay_message", "Relay message is not binary data"); +} + +function isRelayFrame(bytes: Uint8Array): boolean { + return ( + bytes.byteLength >= 4 && + bytes[0] === 0x41 && + bytes[1] === 0x58 && + bytes[2] === 0x4c && + bytes[3] === 0x52 + ); +} + +function admissionBytes(credential: RelayAdmissionCredential): Uint8Array { + const bytes = new TextEncoder().encode( + JSON.stringify({ + version: REMOTE_TRANSPORT_VERSION, + ticket: credential.ticket, + connectionNonce: credential.connectionNonce, + possessionProof: encodeBase64(credential.possessionProof), + }), + ); + if (bytes.byteLength === 0 || bytes.byteLength > MAX_ADMISSION_BYTES) { + throw new RemoteRelayError("invalid_admission", "Relay admission message exceeds its bound"); + } + return bytes; +} + +type RouteWaiter = { + readonly resolve: (route: RouteId) => void; + readonly reject: (error: Error) => void; + readonly timer: ReturnType; +}; + +/** Ticket-admitted opaque WebSocket connection with bounded reconnect and route discovery. */ +export class RemoteRelayConnection { + private readonly options: RemoteRelayConnectionOptions; + private readonly sockets: RemoteWebSocketFactory; + private readonly policy: RemoteReconnectPolicy; + private readonly routeWaitMs: number; + private readonly random: () => number; + private readonly sleep: (milliseconds: number) => Promise; + private readonly receiptListeners = new Set<(receipt: RelayReceipt) => void>(); + private readonly failureListeners = new Set<(failure: RelayFailure) => void>(); + private readonly deliveryListeners = new Set<(delivery: RelayDelivery) => void>(); + private readonly routeListeners = new Set<(peers: readonly RelayPeerRoute[]) => void>(); + private readonly stateListeners = new Set<(state: RemoteRelayConnectionState) => void>(); + private readonly routeWaiters = new Set(); + private socket: RemoteWebSocket | undefined; + private sourceRoute: RelayPeerRoute | undefined; + private peers = new Map(); + private generation = 0; + private stopped = true; + private reconnecting: Promise | undefined; + private currentState: RemoteRelayConnectionState = "disconnected"; + + constructor(options: RemoteRelayConnectionOptions) { + this.options = options; + this.sockets = options.sockets ?? new GlobalRemoteWebSocketFactory(); + this.policy = reconnectPolicy(options.reconnect); + this.routeWaitMs = positiveInteger(options.routeWaitMs ?? DEFAULT_ROUTE_WAIT_MS, "routeWaitMs"); + this.random = options.random ?? Math.random; + this.sleep = + options.sleep ?? + ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + } + + get state(): RemoteRelayConnectionState { + return this.currentState; + } + + get routes(): readonly RelayPeerRoute[] { + return [...this.peers.values()]; + } + + get source(): RelayPeerRoute | undefined { + return this.sourceRoute; + } + + async start(): Promise { + if (!this.stopped) return; + this.stopped = false; + await this.connectWithRetry("connecting"); + } + + close(): void { + if (this.stopped && this.currentState === "closed") return; + this.stopped = true; + this.generation += 1; + this.socket?.close(1000, "client_closed"); + this.socket = undefined; + this.clearRoutes(); + this.rejectRouteWaiters( + new RemoteRelayError("connection_closed", "Relay connection is closed"), + ); + this.setState("closed"); + } + + onState(listener: (state: RemoteRelayConnectionState) => void): () => void { + this.stateListeners.add(listener); + return () => this.stateListeners.delete(listener); + } + + onRoutes(listener: (peers: readonly RelayPeerRoute[]) => void): () => void { + this.routeListeners.add(listener); + return () => this.routeListeners.delete(listener); + } + + onReceipt(listener: (receipt: RelayReceipt) => void): () => void { + this.receiptListeners.add(listener); + return () => this.receiptListeners.delete(listener); + } + + onFailure(listener: (failure: RelayFailure) => void): () => void { + this.failureListeners.add(listener); + return () => this.failureListeners.delete(listener); + } + + onDelivery(listener: (delivery: RelayDelivery) => void): () => void { + this.deliveryListeners.add(listener); + return () => this.deliveryListeners.delete(listener); + } + + async resolve(destinationCryptoSessionId: CryptoSessionId): Promise { + if ( + this.options.destinationCryptoSessionId === undefined || + destinationCryptoSessionId !== this.options.destinationCryptoSessionId + ) { + throw new RemoteRelayError( + "wrong_destination", + "Prepared envelope targets another crypto session", + ); + } + const current = this.daemonRoute(); + if (current !== undefined) return current; + if (this.stopped) throw new RemoteRelayError("connection_closed", "Relay is not running"); + return new Promise((resolve, reject) => { + const waiter: RouteWaiter = { + resolve, + reject, + timer: setTimeout(() => { + this.routeWaiters.delete(waiter); + reject(new RemoteRelayError("daemon_offline", "Daemon route is unavailable")); + }, this.routeWaitMs), + }; + this.routeWaiters.add(waiter); + }); + } + + send( + destinationRouteId: RouteId, + attemptId: RelayDelivery["attemptId"], + payload: Uint8Array, + ): void { + const socket = this.socket; + if (this.currentState !== "connected" || socket === undefined || socket.readyState !== 1) { + throw new RemoteRelayError("connection_closed", "Relay connection is not connected"); + } + socket.send( + encodeRelayBinaryFrame({ + transportVersion: REMOTE_TRANSPORT_VERSION, + attemptId, + destinationRouteId, + opaquePayload: payload, + }), + ); + } + + private async connectWithRetry(state: "connecting" | "reconnecting"): Promise { + this.setState(state); + let latest: unknown; + for (let attempt = 0; attempt < this.policy.maximumAttempts && !this.stopped; attempt += 1) { + if (attempt > 0) await this.sleep(this.retryDelay(attempt - 1)); + try { + await this.connectOnce(); + return; + } catch (error) { + latest = error; + } + } + if (this.stopped) return; + this.setState("disconnected"); + throw new RemoteRelayError("connection_failed", "Relay reconnect attempts were exhausted", { + cause: latest, + }); + } + + private async connectOnce(): Promise { + const credential = await this.options.tickets.acquire(); + if (credential.expiresAt <= Date.now()) { + throw new RemoteRelayError("invalid_admission", "Relay ticket is already expired"); + } + const generation = ++this.generation; + const socket = this.sockets.connect(credential.relayUrl); + socket.binaryType = "arraybuffer"; + this.socket = socket; + + await new Promise((resolve, reject) => { + let settled = false; + const finish = (error?: Error): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (error === undefined) resolve(); + else reject(error); + }; + const timer = setTimeout( + () => { + socket.close(1008, "route_snapshot_timeout"); + finish(new RemoteRelayError("connection_failed", "Relay route snapshot timed out")); + }, + Math.min(this.routeWaitMs, credential.limits.idleTimeoutMs), + ); + const open: RemoteWebSocketListener = () => { + try { + socket.send(admissionBytes(credential)); + } catch (cause) { + finish( + new RemoteRelayError("invalid_admission", "Could not send relay admission", { cause }), + ); + } + }; + const message: RemoteWebSocketListener = (event) => { + if (event.type !== "message") return; + void this.handleMessage(event.data, generation) + .then((snapshot) => { + if (snapshot) finish(); + }) + .catch((cause: unknown) => { + socket.close(1008, "bad_relay_message"); + finish( + new RemoteRelayError("bad_relay_message", "Relay sent an invalid message", { cause }), + ); + }); + }; + const closed: RemoteWebSocketListener = (event) => { + if (event.type !== "close") return; + const error = new RemoteRelayError( + "connection_closed", + `Relay closed during admission${event.reason ? `: ${event.reason}` : ""}`, + ); + finish(error); + this.handleSocketClosed(generation, error); + }; + const failed: RemoteWebSocketListener = () => + finish(new RemoteRelayError("connection_failed", "Relay WebSocket failed")); + socket.addEventListener("open", open); + socket.addEventListener("message", message); + socket.addEventListener("close", closed); + socket.addEventListener("error", failed); + }); + if (generation !== this.generation || this.stopped) { + socket.close(1000, "stale_connection"); + throw new RemoteRelayError("connection_closed", "Relay connection became stale"); + } + this.setState("connected"); + } + + private async handleMessage(value: unknown, generation: number): Promise { + if (generation !== this.generation || this.stopped) return false; + const bytes = await messageBytes(value); + if (!isRelayFrame(bytes)) { + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + } catch (cause) { + throw new RemoteRelayError("bad_relay_message", "Relay discovery is invalid JSON", { + cause, + }); + } + const discovery = parseRelayDiscoveryMessage(parsed); + this.applyDiscovery(discovery); + return discovery.type === "route_snapshot"; + } + const frame = parseRelayBinaryFrame(bytes); + if ("status" in frame) { + for (const listener of this.receiptListeners) listener(frame); + } else if ("code" in frame) { + for (const listener of this.failureListeners) listener(frame); + } else if ("sourceRouteId" in frame) { + for (const listener of this.deliveryListeners) listener(frame); + } else { + throw new RemoteRelayError("bad_relay_message", "Relay sent a client-only frame"); + } + return false; + } + + private applyDiscovery(message: ReturnType): void { + if (message.type === "route_snapshot") { + this.sourceRoute = message.sourceRoute; + this.peers = new Map(message.peers.map((peer) => [peer.routeId, peer])); + } else if (message.type === "route_available") { + for (const peer of message.peers) { + for (const [routeId, current] of this.peers) { + if (current.role === peer.role && current.deviceId === peer.deviceId) { + this.peers.delete(routeId); + } + } + this.peers.set(peer.routeId, peer); + } + } else { + for (const peer of message.peers) this.peers.delete(peer.routeId); + } + this.resolveRouteWaiters(); + for (const listener of this.routeListeners) listener(this.routes); + } + + private daemonRoute(): RouteId | undefined { + return this.routes.find((peer) => peer.role === "daemon")?.routeId; + } + + private resolveRouteWaiters(): void { + const route = this.daemonRoute(); + if (route === undefined) return; + for (const waiter of this.routeWaiters) { + clearTimeout(waiter.timer); + waiter.resolve(route); + } + this.routeWaiters.clear(); + } + + private rejectRouteWaiters(error: Error): void { + for (const waiter of this.routeWaiters) { + clearTimeout(waiter.timer); + waiter.reject(error); + } + this.routeWaiters.clear(); + } + + private clearRoutes(): void { + this.sourceRoute = undefined; + if (this.peers.size === 0) return; + this.peers.clear(); + for (const listener of this.routeListeners) listener([]); + } + + private handleSocketClosed(generation: number, error: Error): void { + if (generation !== this.generation) return; + const wasConnected = this.currentState === "connected"; + this.socket = undefined; + this.clearRoutes(); + this.rejectRouteWaiters(error); + if (!wasConnected || this.stopped || this.reconnecting !== undefined) return; + const reconnecting = this.connectWithRetry("reconnecting").catch(() => undefined); + this.reconnecting = reconnecting; + void reconnecting.finally(() => { + if (this.reconnecting === reconnecting) this.reconnecting = undefined; + }); + } + + private retryDelay(attempt: number): number { + const unjittered = Math.min( + this.policy.maximumDelayMs, + this.policy.initialDelayMs * 2 ** attempt, + ); + const random = this.random(); + if (!Number.isFinite(random) || random < 0 || random > 1) { + throw new TypeError("Reconnect random source must return a value from zero through one"); + } + const factor = 1 - this.policy.jitterRatio + 2 * this.policy.jitterRatio * random; + return Math.max(1, Math.round(unjittered * factor)); + } + + private setState(state: RemoteRelayConnectionState): void { + if (state === this.currentState) return; + this.currentState = state; + for (const listener of this.stateListeners) listener(state); + } +} + +export interface AuthenticatedRemotePayload { + readonly authenticatedPeerId: DeviceId; + readonly plaintext: Uint8Array; +} + +export interface RemotePayloadOpener { + open(opaqueEnvelope: Uint8Array): Promise; +} + +export interface RemoteDeliveryUpdate { + readonly requestId: RequestId; + readonly state: RemoteDeliveryState; + readonly attemptId?: RelayReceipt["attemptId"]; + readonly relayFailure?: RelayFailure["code"]; +} + +export interface RemoteHostedDeliveryOptions { + readonly connection: RemoteRelayConnection; + readonly outbox: OpaqueOutbox; + readonly opener: RemotePayloadOpener; + readonly expectedDaemonId: DeviceId; + readonly attemptIds: TransportAttemptIdFactory; +} + +/** Coordinates immutable prepared envelopes. It never creates or advances cryptographic state. */ +export class RemoteHostedDelivery { + private readonly options: RemoteHostedDeliveryOptions; + private readonly attempts = new Map(); + private readonly deliveryListeners = new Set<(update: RemoteDeliveryUpdate) => void>(); + private readonly messageListeners = new Set<(message: RemoteDaemonMessage) => void>(); + private readonly errorListeners = new Set<(error: Error) => void>(); + private flushTail: Promise = Promise.resolve(); + private inboundTail: Promise = Promise.resolve(); + private started = false; + + constructor(options: RemoteHostedDeliveryOptions) { + this.options = options; + options.connection.onState((state) => { + if (state === "reconnecting" || state === "disconnected") { + this.attempts.clear(); + void options.outbox + .resetSendingAfterDisconnect() + .catch((cause: unknown) => this.reportError(cause, "Could not reset the remote outbox")); + } + if (state === "connected") { + void this.flush().catch((cause: unknown) => + this.reportError(cause, "Could not flush the remote outbox"), + ); + } + }); + options.connection.onRoutes(() => { + void this.flush().catch((cause: unknown) => + this.reportError(cause, "Could not flush the remote outbox"), + ); + }); + options.connection.onReceipt((receipt) => this.handleReceipt(receipt)); + options.connection.onFailure((failure) => { + void this.handleFailure(failure).catch((cause: unknown) => + this.reportError(cause, "Could not apply a relay failure"), + ); + }); + options.connection.onDelivery((delivery) => { + this.inboundTail = this.inboundTail + .then(() => this.handleDelivery(delivery)) + .catch((cause) => { + const error = + cause instanceof Error + ? cause + : new RemoteRelayError("bad_relay_message", "Remote delivery failed", { cause }); + for (const listener of this.errorListeners) listener(error); + }); + }); + } + + onDeliveryState(listener: (update: RemoteDeliveryUpdate) => void): () => void { + this.deliveryListeners.add(listener); + return () => this.deliveryListeners.delete(listener); + } + + onMessage(listener: (message: RemoteDaemonMessage) => void): () => void { + this.messageListeners.add(listener); + return () => this.messageListeners.delete(listener); + } + + onError(listener: (error: Error) => void): () => void { + this.errorListeners.add(listener); + return () => this.errorListeners.delete(listener); + } + + async start(): Promise { + if (this.started) return; + this.started = true; + await this.options.connection.start(); + await this.flush(); + } + + close(): void { + this.started = false; + this.options.connection.close(); + } + + async enqueuePrepared(record: OpaqueOutboxRecord): Promise { + await this.options.outbox.enqueue(record); + this.publish({ requestId: record.requestId, state: "queued_local" }); + await this.flush(); + } + + async sendPreparedEphemeral( + destinationCryptoSessionId: CryptoSessionId, + opaqueEnvelope: Uint8Array, + ): Promise { + const destinationRouteId = await this.options.connection.resolve(destinationCryptoSessionId); + const attemptId = this.options.attemptIds.create(); + this.options.connection.send(destinationRouteId, attemptId, opaqueEnvelope); + return attemptId; + } + + flush(): Promise { + const operation = this.flushTail.then(async () => { + if (!this.started || this.options.connection.state !== "connected") return; + const records = await this.options.outbox.list(); + let firstFailure: unknown; + for (const record of records) { + if (record.state !== "queued_local") continue; + try { + const attempt = await this.options.outbox.beginAttempt(record.requestId); + this.attempts.set(attempt.attemptId, attempt.requestId); + this.publish({ + requestId: attempt.requestId, + state: "sending", + attemptId: attempt.attemptId, + }); + this.options.connection.send( + attempt.destinationRouteId, + attempt.attemptId, + attempt.opaqueEnvelope, + ); + } catch (cause) { + await this.options.outbox.markQueued(record.requestId); + firstFailure ??= cause; + this.reportError(cause, "Could not send a prepared remote envelope"); + } + } + if (firstFailure !== undefined) throw firstFailure; + }); + this.flushTail = operation.catch(() => undefined); + return operation; + } + + private handleReceipt(receipt: RelayReceipt): void { + const requestId = this.attempts.get(receipt.attemptId); + if (requestId === undefined) return; + this.publish({ + requestId, + state: receipt.status === "admitted" ? "relay_admitted" : "relay_forwarded", + attemptId: receipt.attemptId, + }); + if (receipt.status === "forwarded") this.attempts.delete(receipt.attemptId); + } + + private async handleFailure(failure: RelayFailure): Promise { + const requestId = this.attempts.get(failure.attemptId); + if (requestId === undefined) return; + this.attempts.delete(failure.attemptId); + await this.options.outbox.markQueued(requestId); + this.publish({ + requestId, + state: "failed", + attemptId: failure.attemptId, + relayFailure: failure.code, + }); + } + + private async handleDelivery(delivery: RelayDelivery): Promise { + const opened = await this.options.opener.open(delivery.opaquePayload); + if (parseDeviceId(opened.authenticatedPeerId) !== this.options.expectedDaemonId) { + throw new RemoteRelayError( + "bad_relay_message", + "Delivery is not authenticated to the daemon", + ); + } + const message = decodeRemoteDaemonMessage(opened.plaintext); + if (message.type === "daemon_accepted") { + const record = (await this.options.outbox.list()).find( + (candidate) => candidate.requestId === message.requestId, + ); + if (record === undefined || record.idempotencyKey !== message.idempotencyKey) { + throw new RemoteRelayError( + "bad_relay_message", + "Daemon acceptance does not match the prepared request", + ); + } + await this.options.outbox.markDaemonAccepted(message.requestId); + this.publish({ requestId: message.requestId, state: "daemon_accepted" }); + await this.options.outbox.removeAccepted(message.requestId); + } else if (message.type === "daemon_result") { + this.publish({ requestId: message.requestId, state: "completed" }); + } else if (message.type === "daemon_error") { + this.publish({ requestId: message.requestId, state: "failed" }); + } + for (const listener of this.messageListeners) listener(message); + } + + private publish(update: RemoteDeliveryUpdate): void { + for (const listener of this.deliveryListeners) listener(update); + } + + private reportError(cause: unknown, message: string): void { + const error = + cause instanceof Error + ? cause + : new RemoteRelayError("connection_failed", message, { cause }); + for (const listener of this.errorListeners) listener(error); + } +} diff --git a/packages/sdk/test/remote-outbox.test.ts b/packages/sdk/test/remote-outbox.test.ts index 80814992..d5ec393b 100644 --- a/packages/sdk/test/remote-outbox.test.ts +++ b/packages/sdk/test/remote-outbox.test.ts @@ -5,6 +5,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + parseCryptoSessionId, parseIdempotencyKey, parseRemoteRequestId, parseRouteId, @@ -49,38 +50,52 @@ class MemoryOutboxStore implements OpaqueOutboxStore { const requestId = parseRemoteRequestId("11111111-1111-4111-8111-111111111111"); const idempotencyKey = parseIdempotencyKey("22222222-2222-4222-8222-222222222222"); -const destinationRouteId = parseRouteId("33333333-3333-4333-8333-333333333333"); +const destinationCryptoSessionId = parseCryptoSessionId("33333333-3333-4333-8333-333333333333"); +const firstRouteId = parseRouteId("55555555-5555-4555-8555-555555555555"); +const secondRouteId = parseRouteId("66666666-6666-4666-8666-666666666666"); function record(bytes = Uint8Array.of(0, 1, 2, 255)): OpaqueOutboxRecord { return { requestId, idempotencyKey, - destinationRouteId, + destinationCryptoSessionId, opaqueEnvelope: bytes, createdAt: 1_900_000_000_000, state: "queued_local", }; } -function outbox(): OpaqueOutbox { +function outbox(route = () => firstRouteId): OpaqueOutbox { let attempt = 0; - return new OpaqueOutbox(new MemoryOutboxStore(), { - create() { - attempt += 1; - return parseTransportAttemptId( - `44444444-4444-4444-8444-${attempt.toString().padStart(12, "0")}`, - ); + return new OpaqueOutbox( + new MemoryOutboxStore(), + { + create() { + attempt += 1; + return parseTransportAttemptId( + `44444444-4444-4444-8444-${attempt.toString().padStart(12, "0")}`, + ); + }, }, - }); + { + async resolve() { + return route(); + }, + }, + ); } -test("retries exact opaque bytes under new transport attempt IDs", async () => { - const queue = outbox(); +test("resolves a fresh ephemeral route while retrying exact opaque bytes", async () => { + let currentRoute = firstRouteId; + const queue = outbox(() => currentRoute); await queue.enqueue(record()); const first = await queue.beginAttempt(requestId); + currentRoute = secondRouteId; const second = await queue.beginAttempt(requestId); assert.notEqual(first.attemptId, second.attemptId); + assert.equal(first.destinationRouteId, firstRouteId); + assert.equal(second.destinationRouteId, secondRouteId); assert.deepEqual(first.opaqueEnvelope, Uint8Array.of(0, 1, 2, 255)); assert.deepEqual(second.opaqueEnvelope, first.opaqueEnvelope); assert.equal((await queue.list())[0]?.state, "sending"); diff --git a/packages/sdk/test/remote-relay.test.ts b/packages/sdk/test/remote-relay.test.ts new file mode 100644 index 00000000..674b3c70 --- /dev/null +++ b/packages/sdk/test/remote-relay.test.ts @@ -0,0 +1,348 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULT_RELAY_LIMITS, + REMOTE_TRANSPORT_VERSION, + encodeRelayBinaryFrame, + encodeRemoteDaemonMessage, + parseCryptoSessionId, + parseDeviceId, + parseIdempotencyKey, + parseInstallationId, + parseRelayBinaryFrame, + parseRemoteRequestId, + parseRouteId, + parseTransportAttemptId, + type OpaqueOutboxRecord, + type RequestId, +} from "@axl/protocol"; + +import { + OpaqueOutbox, + type OpaqueOutboxStore, + type OpaqueOutboxTransaction, +} from "../src/remote-outbox.ts"; +import { + HttpRelayTicketProvider, + RemoteHostedDelivery, + RemoteRelayConnection, + type RelayAdmissionCredential, + type RemoteRelayConnectionState, + type RemoteWebSocket, + type RemoteWebSocketEvent, + type RemoteWebSocketFactory, +} from "../src/remote-relay.ts"; + +class MemoryOutboxStore implements OpaqueOutboxStore { + readonly records = new Map(); + + async transact( + requestId: RequestId, + operation: (current: OpaqueOutboxRecord | undefined) => OpaqueOutboxTransaction, + ): Promise { + const transaction = operation(this.records.get(requestId)); + if (transaction.record === undefined) this.records.delete(requestId); + else this.records.set(requestId, transaction.record); + return transaction.result; + } + + async list(): Promise { + return [...this.records.values()]; + } +} + +type Listener = (event: RemoteWebSocketEvent) => void; + +class FakeSocket implements RemoteWebSocket { + binaryType = ""; + readyState = 0; + readonly sent: Uint8Array[] = []; + private readonly listeners = new Map>(); + + send(data: Uint8Array): void { + if (this.readyState !== 1) throw new Error("socket is not open"); + this.sent.push(data.slice()); + } + + close(code = 1000, reason = ""): void { + if (this.readyState === 3) return; + this.readyState = 3; + this.emit({ type: "close", code, reason }); + } + + addEventListener(type: RemoteWebSocketEvent["type"], listener: Listener): void { + const listeners = this.listeners.get(type) ?? new Set(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type: RemoteWebSocketEvent["type"], listener: Listener): void { + this.listeners.get(type)?.delete(listener); + } + + open(): void { + this.readyState = 1; + this.emit({ type: "open" }); + } + + message(data: Uint8Array): void { + this.emit({ type: "message", data }); + } + + private emit(event: RemoteWebSocketEvent): void { + for (const listener of this.listeners.get(event.type) ?? []) listener(event); + } +} + +class FakeSocketFactory implements RemoteWebSocketFactory { + readonly sockets: FakeSocket[] = []; + + connect(): RemoteWebSocket { + const socket = new FakeSocket(); + this.sockets.push(socket); + return socket; + } +} + +const installationId = parseInstallationId("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); +const cryptoSessionId = parseCryptoSessionId("11111111-1111-4111-8111-111111111111"); +const deviceRoute = parseRouteId("22222222-2222-4222-8222-222222222222"); +const firstDaemonRoute = parseRouteId("33333333-3333-4333-8333-333333333333"); +const secondDaemonRoute = parseRouteId("44444444-4444-4444-8444-444444444444"); +const requestId = parseRemoteRequestId("55555555-5555-4555-8555-555555555555"); +const idempotencyKey = parseIdempotencyKey("66666666-6666-4666-8666-666666666666"); +const daemonId = parseDeviceId("77777777-7777-4777-8777-777777777777"); + +function credential(): RelayAdmissionCredential { + return { + ticket: "one-use-ticket", + relayUrl: "wss://relay.invalid/v1/connect", + expiresAt: Date.now() + 60_000, + proofSchemeVersion: 1, + limits: DEFAULT_RELAY_LIMITS, + connectionNonce: "nonce", + possessionProof: Uint8Array.of(1, 2, 3), + }; +} + +function discovery( + type: "route_snapshot" | "route_available" | "route_unavailable", + route: string, +) { + return new TextEncoder().encode( + JSON.stringify({ + version: 1, + type, + ...(type === "route_snapshot" + ? { sourceRoute: { routeId: deviceRoute, role: "device", deviceId: daemonId } } + : {}), + peers: [{ routeId: route, role: "daemon" }], + }), + ); +} + +async function nextTurn(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +async function connect( + factory: FakeSocketFactory, +): Promise<{ readonly connection: RemoteRelayConnection; readonly socket: FakeSocket }> { + const connection = new RemoteRelayConnection({ + tickets: { + async acquire() { + return credential(); + }, + }, + sockets: factory, + destinationCryptoSessionId: cryptoSessionId, + reconnect: { initialDelayMs: 1, maximumDelayMs: 1, jitterRatio: 0, maximumAttempts: 3 }, + routeWaitMs: 100, + sleep: async () => undefined, + }); + const starting = connection.start(); + await nextTurn(); + const socket = factory.sockets[0]; + assert.ok(socket); + socket.open(); + socket.message(discovery("route_snapshot", firstDaemonRoute)); + await starting; + return { connection, socket }; +} + +test("acquires tickets in an authenticated body request and rejects insecure production origins", async () => { + assert.throws( + () => + new HttpRelayTicketProvider({ + controlPlaneOrigin: "http://control.invalid", + request: { + installationId, + deviceId: daemonId, + role: "device", + }, + authenticationHeaders: async () => ({}), + proof: { + async create() { + return { connectionNonce: "nonce", possessionProof: Uint8Array.of(1) }; + }, + }, + }), + /must use HTTPS/, + ); + + let requestedUrl = ""; + const provider = new HttpRelayTicketProvider({ + controlPlaneOrigin: "http://127.0.0.1:1234", + request: { + installationId, + deviceId: daemonId, + role: "device", + }, + authenticationHeaders: async () => ({ authorization: "Bearer fixture" }), + proof: { + async create() { + return { connectionNonce: "nonce", possessionProof: Uint8Array.of(1, 2, 3) }; + }, + }, + allowInsecureLoopbackForTests: true, + fetch: async (url, init) => { + requestedUrl = url; + assert.equal(init.headers.authorization, "Bearer fixture"); + assert.doesNotMatch(url, /one-use-ticket/); + return { + ok: true, + status: 201, + async json() { + const { connectionNonce: _nonce, possessionProof: _proof, ...issued } = credential(); + return issued; + }, + }; + }, + }); + const acquired = await provider.acquire(); + assert.equal(requestedUrl, "http://127.0.0.1:1234/v1/relay/tickets"); + assert.equal(acquired.ticket, "one-use-ticket"); + assert.deepEqual(acquired.possessionProof, Uint8Array.of(1, 2, 3)); +}); + +test("admits with a bounded first binary message and tracks route replacement", async () => { + const factory = new FakeSocketFactory(); + const { connection, socket } = await connect(factory); + + assert.equal(socket.binaryType, "arraybuffer"); + const admission = JSON.parse(new TextDecoder().decode(socket.sent[0])) as Record; + assert.equal(admission.ticket, "one-use-ticket"); + assert.equal(admission.connectionNonce, "nonce"); + assert.equal(await connection.resolve(cryptoSessionId), firstDaemonRoute); + + socket.message(discovery("route_available", secondDaemonRoute)); + await nextTurn(); + assert.equal(await connection.resolve(cryptoSessionId), secondDaemonRoute); + socket.message(discovery("route_unavailable", secondDaemonRoute)); + await nextTurn(); + await assert.rejects(connection.resolve(cryptoSessionId), /Daemon route is unavailable/); + connection.close(); +}); + +test("reconnect resolves a new route and retries byte-identical prepared ciphertext", async () => { + const factory = new FakeSocketFactory(); + const { connection, socket: firstSocket } = await connect(factory); + const store = new MemoryOutboxStore(); + let attempt = 0; + const attemptIds = { + create() { + attempt += 1; + return parseTransportAttemptId( + `88888888-8888-4888-8888-${attempt.toString().padStart(12, "0")}`, + ); + }, + }; + const outbox = new OpaqueOutbox(store, attemptIds, connection); + const states: RemoteRelayConnectionState[] = []; + connection.onState((state) => states.push(state)); + const updates: string[] = []; + const delivery = new RemoteHostedDelivery({ + connection, + outbox, + expectedDaemonId: daemonId, + attemptIds, + opener: { + async open(opaqueEnvelope) { + return { authenticatedPeerId: daemonId, plaintext: opaqueEnvelope }; + }, + }, + }); + delivery.onDeliveryState((update) => updates.push(update.state)); + await delivery.start(); + + const opaqueEnvelope = Uint8Array.of(0, 1, 2, 255); + await delivery.enqueuePrepared({ + requestId, + idempotencyKey, + destinationCryptoSessionId: cryptoSessionId, + opaqueEnvelope, + createdAt: 1_900_000_000_000, + state: "queued_local", + }); + const firstSend = parseRelayBinaryFrame(firstSocket.sent.at(-1) ?? new Uint8Array()); + assert.ok("destinationRouteId" in firstSend); + assert.equal(firstSend.destinationRouteId, firstDaemonRoute); + assert.deepEqual(firstSend.opaquePayload, opaqueEnvelope); + + firstSocket.close(1006, "restart"); + await nextTurn(); + await nextTurn(); + const secondSocket = factory.sockets[1]; + assert.ok(secondSocket); + secondSocket.open(); + secondSocket.message(discovery("route_snapshot", secondDaemonRoute)); + await nextTurn(); + await nextTurn(); + + const secondSend = parseRelayBinaryFrame(secondSocket.sent.at(-1) ?? new Uint8Array()); + assert.ok("destinationRouteId" in secondSend); + assert.equal(secondSend.destinationRouteId, secondDaemonRoute); + assert.notEqual(secondSend.attemptId, firstSend.attemptId); + assert.deepEqual(secondSend.opaquePayload, firstSend.opaquePayload); + + secondSocket.message( + encodeRelayBinaryFrame({ + transportVersion: REMOTE_TRANSPORT_VERSION, + attemptId: secondSend.attemptId, + status: "admitted", + }), + ); + secondSocket.message( + encodeRelayBinaryFrame({ + transportVersion: REMOTE_TRANSPORT_VERSION, + attemptId: secondSend.attemptId, + status: "forwarded", + }), + ); + secondSocket.message( + encodeRelayBinaryFrame({ + transportVersion: REMOTE_TRANSPORT_VERSION, + attemptId: parseTransportAttemptId("99999999-9999-4999-8999-999999999999"), + sourceRouteId: secondDaemonRoute, + opaquePayload: encodeRemoteDaemonMessage({ + version: REMOTE_TRANSPORT_VERSION, + type: "daemon_accepted", + requestId, + idempotencyKey, + }), + }), + ); + await nextTurn(); + await nextTurn(); + + assert.ok(states.includes("reconnecting")); + assert.ok(updates.includes("relay_admitted")); + assert.ok(updates.includes("relay_forwarded")); + assert.ok(updates.includes("daemon_accepted")); + assert.deepEqual(await outbox.list(), []); + delivery.close(); +}); diff --git a/services/relay/README.md b/services/relay/README.md index 4921482a..a1824138 100644 --- a/services/relay/README.md +++ b/services/relay/README.md @@ -16,4 +16,4 @@ The first slice provides: - explicit inbound heartbeat deadlines, lease expiry, generation-bound revocation, and draining - fail-closed admission and internal-authentication interfaces -Production control-plane origins, service authentication, TLS termination, and deployment configuration remain unselected. Tests use deterministic fake adapters. +Production control-plane origins, service authentication, TLS termination, and deployment configuration remain unselected. Tests use deterministic fake adapters. The disposable cross-runtime hosted-path test may explicitly allow plain HTTP only for an exact loopback control-plane origin; production configuration remains HTTPS-only. diff --git a/services/relay/lib/axl_relay/http_control_plane_client.ex b/services/relay/lib/axl_relay/http_control_plane_client.ex index 02eae8c6..65a80c8a 100644 --- a/services/relay/lib/axl_relay/http_control_plane_client.ex +++ b/services/relay/lib/axl_relay/http_control_plane_client.ex @@ -9,7 +9,7 @@ defmodule AxlRelay.HttpControlPlaneClient do @impl true def consume_ticket(admission, relay_instance_id, options) do with {:ok, origin} <- Keyword.fetch(options, :origin), - true <- valid_origin?(origin), + true <- valid_origin?(origin, options), {:ok, headers} when headers != [] <- Keyword.fetch(options, :headers), body <- :json.encode(%{ @@ -29,18 +29,22 @@ defmodule AxlRelay.HttpControlPlaneClient do end end - defp valid_origin?(origin) when is_binary(origin) do + defp valid_origin?(origin, options) when is_binary(origin) do case URI.parse(origin) do %URI{scheme: "https", host: host, path: path, query: nil, fragment: nil, userinfo: nil} when is_binary(host) and path in [nil, ""] -> true + %URI{scheme: "http", host: host, path: path, query: nil, fragment: nil, userinfo: nil} + when host in ["127.0.0.1", "::1"] and path in [nil, ""] -> + Keyword.get(options, :allow_insecure_loopback_for_tests, false) == true + _other -> false end end - defp valid_origin?(_origin), do: false + defp valid_origin?(_origin, _options), do: false defp post(origin, headers, body) do url = String.to_charlist(origin <> "/internal/v1/relay/tickets/consume") diff --git a/services/relay/test/support/hosted_path_server.exs b/services/relay/test/support/hosted_path_server.exs new file mode 100644 index 00000000..efd8ad36 --- /dev/null +++ b/services/relay/test/support/hosted_path_server.exs @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.HostedPathTestAuthenticator do + @behaviour AxlRelay.InternalAuthenticator + + @impl true + def authenticate(connection, _body, _options) do + Plug.Conn.get_req_header(connection, "authorization") == ["Bearer internal-fixture"] + end +end + +port = System.fetch_env!("AXL_RELAY_TEST_PORT") |> String.to_integer() +control_plane_origin = System.fetch_env!("AXL_CONTROL_PLANE_TEST_ORIGIN") + +{:ok, _listener} = + AxlRelay.Listener.start_link( + scheme: :http, + port: port, + ip: {127, 0, 0, 1}, + connection_options: [ + control_plane: AxlRelay.HttpControlPlaneClient, + control_plane_options: [ + origin: control_plane_origin, + headers: [{~c"authorization", ~c"Bearer internal-fixture"}], + allow_insecure_loopback_for_tests: true + ], + relay_instance_id: "hosted-path-test", + registry: AxlRelay.RouteRegistry + ], + internal_authenticator: AxlRelay.HostedPathTestAuthenticator, + registry: AxlRelay.RouteRegistry + ) + +IO.puts("AXL_RELAY_TEST_READY") +Process.sleep(:infinity) From e54e3c7ce0adf3dcf5ed4c01108db4e44ad364dd Mon Sep 17 00:00:00 2001 From: VishnuM049 Date: Tue, 15 Sep 2026 01:30:26 +0530 Subject: [PATCH 12/16] ci: run security gates for RC pull requests Signed-off-by: VishnuM049 --- .github/workflows/codeql.yml | 2 +- .github/workflows/dependency-review.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b0fc0de1..7895d493 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -7,7 +7,7 @@ on: push: branches: [main, "release/**"] pull_request: - branches: [main, "release/**"] + branches: [main, RC, "release/**"] merge_group: types: [checks_requested] schedule: diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index de8c6f08..e369cb29 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -5,7 +5,7 @@ name: Dependency Review on: pull_request: - branches: [main, "release/**"] + branches: [main, RC, "release/**"] merge_group: types: [checks_requested] From dfadd481976273bf01ad1e9c644f6c209d3104a7 Mon Sep 17 00:00:00 2001 From: VishnuM049 Date: Tue, 15 Sep 2026 01:48:15 +0530 Subject: [PATCH 13/16] docs: define hybrid OpenMLS remote profile Signed-off-by: VishnuM049 --- CODE_STRUCTURE.md | 6 +- ROADMAP.md | 18 +- docs/architecture/decisions.md | 6 + docs/architecture/e2ee-transport-preflight.md | 12 +- docs/architecture/remote-e2ee-openmls.md | 313 ++++++++++++++++++ 5 files changed, 338 insertions(+), 17 deletions(-) create mode 100644 docs/architecture/remote-e2ee-openmls.md diff --git a/CODE_STRUCTURE.md b/CODE_STRUCTURE.md index 108c5772..19c6b530 100644 --- a/CODE_STRUCTURE.md +++ b/CODE_STRUCTURE.md @@ -32,7 +32,8 @@ Codex offers a useful contrast. Its CLI and Rust core share a repository, while - Use **TypeScript** for the kernel, protocol, daemon, adoption compiler, terminal client, web client, extensions, and hosted control plane. It matches the ecosystems and standards Axl integrates with. - Use **Elixir/OTP only for the hosted ciphertext relay** under `services/relay/`. The relay is a bounded transport process and must not own daemon, RPC, account, persistence, or cryptographic behavior. - Use **Kotlin with Jetpack Compose** for Android and **Swift with SwiftUI** for iOS. Choose protocol code generation when the first of these clients is built. -- Do not add another application language. Tooling should use TypeScript or POSIX shell. +- A narrowly scoped **Rust endpoint-E2EE core** is the only proposed exception. It may be added only after approval of [`docs/architecture/remote-e2ee-openmls.md`](docs/architecture/remote-e2ee-openmls.md), its dependency and license review, and browser/WASM feasibility. It must expose thin Node, browser/WASM, Swift, and Kotlin bindings and must not absorb daemon, SDK, relay, account, authorization, or presentation behavior. +- Do not add another application language outside that reviewed exception. Other tooling should use TypeScript or POSIX shell. ## 3. Repository layout @@ -51,6 +52,7 @@ axl/ web/ # web client ui/ # shared presentation tokens and React renderers sdk/ # shared TypeScript client SDK when multiple clients need it + e2ee/ # proposed Rust endpoint-E2EE core; create only after its architecture gate extensions/ # first-party extensions, one package per feature (roadmap ยง2.9) services/ control-plane/ # separately deployable TypeScript hosted control plane @@ -71,7 +73,7 @@ These rules keep package ownership clear: - First-party extensions use the same public extension API as third-party extensions. - `packages/protocol` is the only source of wire-format truth. TypeScript definitions stay authoritative until a non-TypeScript presentation client creates a real need for generation. The Elixir relay implements only its narrow transport and internal-service framing against canonical byte and JSON fixtures; it is not a daemon-protocol client. - Apps use the public protocol SDK rather than package internals. -- `services/control-plane` may depend on `packages/protocol`. It owns hosted account, installation, device, ticket, opaque KeyPackage and Welcome rendezvous, grant, upload-reservation, quota, and security-audit mutation. Identity providers, persistent datastores, and production service authentication stay behind injected interfaces until approved. +- `services/control-plane` may depend on `packages/protocol`. It owns hosted account, installation, device, ticket, opaque OpenMLS KeyPackage and Welcome rendezvous, grant, upload-reservation, quota, and security-audit mutation. It never owns private E2EE state, decrypted Welcome contents, MLS group state, or application plaintext. Identity providers, persistent datastores, and production service authentication stay behind injected interfaces until approved. - `services/relay` consumes versioned language-neutral fixtures. It must not import TypeScript package internals, access the control-plane datastore, decrypt envelopes, interpret daemon RPC, persist canonical history, or store attachment bodies. It calls the authenticated control-plane admission API once per new connection and accepts authenticated revocation notifications. - The control plane and relay are separate deployables. They share no private implementation imports and communicate only through their versioned internal HTTP contract. - `packages/runtime` assembles providers, tools, extensions, sandboxing, and the authoritative daemon without importing a presentation client. diff --git a/ROADMAP.md b/ROADMAP.md index c914e9a8..311c56b7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ Status: living product plan and delivery snapshot. -Updated: 2026-09-12 +Updated: 2026-09-13 This document records product intent, candidate designs, and a proposed implementation sequence. It is not normative agent instructions or the sole source of truth. Future features, ordering, languages, frameworks, and technology choices remain plans until adopted by current code or a focused architecture or policy document. @@ -1361,7 +1361,7 @@ Requirements: The current mobile plan favors SwiftUI on iOS and Jetpack Compose on Android because native code supports Live Activities, Android foreground services, notification actions, widgets, share sheets, and efficient streaming text. This is not a binding stack decision. Choose the implementation when mobile work begins and its requirements are concrete. -Remote transport uses pairwise application-level E2EE in addition to TLS. The provisional direction is OpenMLS with one daemon-device group per relationship, opaque KeyPackage and Welcome rendezvous, daemon-only commits, phone Update proposals, and explicit draft-suite migration. Production cryptography remains blocked on Person 1's security RFC, exact suite, reviewed library, browser/WASM feasibility, secure-state transaction, interoperability fixtures, and independent security review. Transport code treats prepared envelopes and rendezvous objects as bounded opaque bytes. The relay never imports the E2EE implementation or decrypts traffic. The proposed remote action-binding and approval rules are in [`docs/architecture/remote-permission-authorization.md`](docs/architecture/remote-permission-authorization.md); that draft does not enable remote approval. +Remote transport uses pairwise application-level E2EE in addition to TLS. The prior PQXDH plus Triple Ratchet direction is superseded. The proposed direction is one two-member OpenMLS group per remote device and daemon installation using a versioned hybrid ML-KEM-768 plus X25519 profile. The daemon is the sole commit creator; a phone generates its own private replacement leaf and sends a signed self-Update proposal. Production cryptography remains blocked on approval of [`docs/architecture/remote-e2ee-openmls.md`](docs/architecture/remote-e2ee-openmls.md), exact pinned dependencies, transactional secure-state design, browser/WASM feasibility, cross-platform fixtures, mobile measurements, and independent security review. Transport code treats every MLS application message, proposal, commit, receipt, KeyPackage, and Welcome as bounded opaque bytes. The relay never imports the E2EE implementation or decrypts traffic. The proposed remote action-binding and approval rules are in [`docs/architecture/remote-permission-authorization.md`](docs/architecture/remote-permission-authorization.md); that draft does not enable remote approval. The managed path uses two separately deployable services: the TypeScript control plane owns hosted state and one-use admission, while the Elixir/OTP relay owns bounded in-memory WebSocket routing. The daemon remains the command and session authority. Transport proof uses only disposable sessions, a deterministic fake provider, opaque fixtures, and a test-only fake E2EE adapter. Ordinary-session steering and remote permission approval remain disabled until the E2EE and release gates pass. @@ -2332,7 +2332,7 @@ The shared remote-connectivity and remote-web subsections are a scoped sequencin #### Shared remote connectivity -- [ ] Write and approve the pairing and remote-transport security RFC before implementing remote access. +- [ ] Approve the pairing and versioned hybrid OpenMLS security RFC before implementing production endpoint E2EE. - [ ] Add revocable device identities and observer, steering, approval, and session-management grants that the daemon maps to protocol capabilities. - [ ] Add bounded encrypted application frames, replay protection, reconnect, and published protocol test vectors. - [ ] Add the daemon's opt-in outbound connection and a ciphertext-only hosted relay with no public daemon port. @@ -2345,7 +2345,7 @@ The shared remote-connectivity and remote-web subsections are a scoped sequencin - [ ] Serve a protocol-independent static account and installation shell from `app.axldev.ai`. - [ ] Retain immutable client bundles by compatible web-asset and wire version rather than placing multiple protocol implementations in one bundle. -- [ ] Store a non-extractable browser device key through an IndexedDB adapter and require re-pairing when it is lost. +- [ ] Store browser OpenMLS identity and group state through the reviewed transactional browser adapter and require re-pairing when protected state is lost. Browser/WASM and durable-storage feasibility must pass before this path is selected. - [ ] Obtain a one-use, device-bound relay ticket through the authenticated API, then authenticate the WebSocket with a bounded initial frame rather than a URL or subprotocol credential. - [ ] Terminate end-to-end encryption in the browser and expose decrypted validated messages through the normal SDK transport contract. - [ ] List installations through the control plane, but obtain sessions, transcripts, and live state only from the selected daemon after encrypted attachment. @@ -2354,21 +2354,21 @@ The shared remote-connectivity and remote-web subsections are a scoped sequencin The transport-first remote-control slice is an approved exception to phase ordering. It may establish service boundaries, opaque framing, one-use ticket admission, bounded relay routing, daemon authorization behind a test-only fake E2EE adapter, and reusable SDK delivery machinery. It must not implement cryptography, select production identity or storage infrastructure, enable ordinary-session remote access, or advertise production remote control. -The private slice was created from clean `main` commit `ea906d0295ba67f833c49ace408a9573551ea687` and rebased for integration onto clean `main` commit `57bd31b7e718a125fc51a0fcf3a554cb100ea708` on `feature/e2ee-transport`. Stop for architecture review after the documentation, separate service boundaries, versioned fixture contract, atomic ticket-consumption path, and first bounded relay slice land. +The original private transport slice was integrated into the shared `RC` branch. Draft PR #394 is the aggregate `RC` to `main` review. Each remaining Person 1 or Person 2 milestone branches from current `RC`, opens a focused PR targeting `RC`, and stops at its own review gate. Contributors do not push implementation directly to `RC` or rewrite shared integration history. #### Remote transport preflight -- [x] Replace the obsolete PQXDH and Triple Ratchet direction with the provisional pairwise OpenMLS profile while keeping exact production cryptography blocked on Person 1's reviewed contract and library. +- [x] Record the prior PQXDH plus Triple Ratchet direction as superseded and propose the versioned pairwise hybrid OpenMLS profile for architecture and security review. - [x] Add the separately deployable TypeScript control plane under `services/control-plane/` with authenticated ticket issuance and atomic one-use consumption through injected interfaces. - [x] Add the separately deployable Elixir/OTP relay under `services/relay/` with authenticated admission, opaque bounded framing, in-memory installation-scoped routing, backpressure, heartbeat, lease, revocation, and draining behavior. - [x] Publish language-neutral admission, revocation, and exact binary accept/reject fixtures consumed by both implementations. - [x] Run TypeScript and Mix formatting, compilation, tests, static analysis, dependency auditing, package-boundary, and SPDX/REUSE checks in CI. - [x] Draft the daemon-owned remote permission action-binding contract without enabling it. -- [x] Stop at the architecture checkpoint before daemon, SDK, cryptographic rendezvous, attachment, or production integration work. +- [x] Stop at the transport architecture checkpoint before daemon, SDK, cryptographic rendezvous, attachment, or production integration work. #### Remote daemon authority checkpoint -The transport checkpoint was approved. The next private slice remains disabled for ordinary sessions and uses only the test fake E2EE adapter. +The transport checkpoint was approved. The next integration slice remains disabled for ordinary sessions and uses only the test fake E2EE adapter. - [x] Define independent `observe`, `steer`, `approve_within_policy`, and `manage_sessions` scopes. - [x] Persist installation-bound local device grants and hosted narrowing generations in the daemon data directory. @@ -2391,7 +2391,7 @@ The transport checkpoint was approved. The next private slice remains disabled f - [x] Permit removal only after authenticated daemon acceptance. - [x] Reset uncertain sending state to queued on reconnect without re-encryption. - [x] Prove the real control plane, relay, daemon authority, SDK, cursor resume, restart, duplicate, revocation, and overflow boundaries in one disposable fake-E2EE test. -- [ ] Connect the opaque outbox to Person 1's reviewed atomic OpenMLS prepared-envelope transaction. +- [ ] Replace the standalone opaque-outbox transaction with Person 1's reviewed OpenMLS transaction that persists state advancement and exact ciphertext together. - [ ] Implement the reviewed bounded authority-audit sink described in [`docs/architecture/remote-hosted-path.md`](docs/architecture/remote-hosted-path.md). #### Mobile clients diff --git a/docs/architecture/decisions.md b/docs/architecture/decisions.md index 12746618..240e0580 100644 --- a/docs/architecture/decisions.md +++ b/docs/architecture/decisions.md @@ -56,6 +56,12 @@ The standard model-visible tools are `read`, `write`, `edit`, `bash`, `web_fetch The built-in web transport uses pinned public DNS results, rejects private and reserved addresses across redirects, forwards no ambient credentials, and bounds request time and response size. Keyless search uses DuckDuckGo Instant Answers. A configured `BRAVE_SEARCH_API_KEY` selects Brave Search and is included in write-boundary redaction. Full egress policy and credential brokering remain later work. +## Remote endpoint E2EE direction + +Proposed decision from 2026-09-13: replace the prior PQXDH plus Triple Ratchet plan with the versioned pairwise hybrid OpenMLS direction in [`remote-e2ee-openmls.md`](remote-e2ee-openmls.md). The proposal uses one two-member group per remote device and daemon installation, hybrid ML-KEM-768 plus X25519 confidentiality, Ed25519 authentication initially, daemon-only commits, phone self-Update proposals, and atomic MLS-state plus exact-ciphertext persistence. + +This is an architecture review decision, not approval to add production dependencies or expose remote control. Exact profile, dependency, browser/WASM, persistence, interoperability, mobile, and independent-review gates remain open. + ## Generated files Generated TypeScript files use the `*.generated.ts` suffix and begin with `@generated by ; do not edit.` Their TypeScript generator must support `--check`, which `pnpm check:generated` runs. Edit the source and regenerate instead of changing generated output directly. diff --git a/docs/architecture/e2ee-transport-preflight.md b/docs/architecture/e2ee-transport-preflight.md index e455207f..dc1d7d13 100644 --- a/docs/architecture/e2ee-transport-preflight.md +++ b/docs/architecture/e2ee-transport-preflight.md @@ -3,11 +3,11 @@ # E2EE transport preflight -Status: architecture review checkpoint +Status: completed transport checkpoint; endpoint cryptography superseded ## Integration base -The private implementation branch is `feature/e2ee-transport`. It was created from clean `main` commit `ea906d0295ba67f833c49ace408a9573551ea687` and rebased for integration onto clean `main` commit `57bd31b7e718a125fc51a0fcf3a554cb100ea708`. +The original private implementation branch was `feature/e2ee-transport`. Its completed transport checkpoint was integrated into the shared `RC` branch. Draft PR #394 is the aggregate `RC` to `main` review. New remote-control milestones use focused branches from current `RC` and target `RC`; they do not push implementation directly to the integration branch. ## Scope @@ -22,13 +22,13 @@ Allowed work is limited to: - bounded routing, queues, heartbeat, lease expiry, revocation, draining, and rate limits - later daemon authorization and SDK delivery tests behind a test-only fake E2EE adapter -Person 1 exclusively owns the OpenMLS profile, pairwise group lifecycle, pairing cryptography, signatures, KeyPackage and Welcome validation and consumption, cryptographic replay behavior, secure epoch-state storage, encryption and decryption, associated data, attachment cryptography, and cryptographic test vectors. +Person 1 exclusively owns the endpoint OpenMLS profile, pairwise group lifecycle, pairing cryptography, credentials and signatures, KeyPackage and Welcome validation and consumption, cryptographic replay and epoch behavior, secure group-state storage, encryption and decryption, associated data, attachment cryptography, and cryptographic test vectors. -The provisional direction is OpenMLS with one pairwise daemon-device group, daemon-only commits, phone Update proposals, an opaque KeyPackage and Welcome rendezvous, and explicit draft-suite migration. No production cryptography may be implemented or enabled until Person 1 supplies an approved RFC, exact suite, reviewed library, browser/WASM feasibility, secure-state transaction, and interoperability fixtures and the integrated result passes independent review. This transport document defines no OpenMLS wire fields or persistence format. +The prior PQXDH plus Triple Ratchet direction is superseded. The proposed successor is the pairwise hybrid post-quantum OpenMLS profile in [Remote endpoint E2EE with OpenMLS](remote-e2ee-openmls.md), with daemon-only commits, phone self-Update proposals, an opaque KeyPackage and Welcome rendezvous, and explicit profile migration. No production cryptography may be implemented or enabled until that specification, exact pinned dependencies, transactional storage contract, browser/WASM feasibility, interoperability fixtures, and independent review pass their gates. This transport document defines no OpenMLS wire fields or persistence format. ## Service ownership -`services/control-plane` is the only hosted component allowed to mutate account, installation, device, ticket, opaque KeyPackage and Welcome rendezvous, grant, upload-reservation, quota, and security-audit state. This slice implements ticket state only. Authentication, authorization, proof verification, clocks, and persistence are injected. Test adapters are deterministic and are not production defaults. +`services/control-plane` is the only hosted component allowed to mutate account, installation, device, ticket, opaque KeyPackage and Welcome rendezvous, grant, upload-reservation, quota, and security-audit state. This slice implements ticket state only. Authentication, authorization, proof verification, clocks, and persistence are injected. Test adapters are deterministic and are not production defaults. The control plane never receives private MLS keys, decrypted Welcome contents, MLS group state, or application plaintext. `services/relay` owns ticket-authenticated WebSocket admission and bounded in-memory routing. It has no database access, E2EE dependency, RPC knowledge, canonical history, durable mailbox, or attachment storage. The relay derives the source route from consumed-ticket state and never accepts it from a sender. @@ -151,4 +151,4 @@ The architecture review selected role-filtered relay discovery, strict opposite- ## Review boundary -This preflight checkpoint was followed by daemon authority, SDK delivery, and a disposable hosted-path test behind fake E2EE. See [`remote-hosted-path.md`](remote-hosted-path.md). OpenMLS rendezvous storage, S3 transport, real E2EE integration, ordinary-session steering, and permission approvals remain separate reviewed milestones. +This historical preflight checkpoint was followed by daemon authority, SDK delivery, and a disposable hosted-path test behind fake E2EE. See [`remote-hosted-path.md`](remote-hosted-path.md). OpenMLS endpoint implementation, KeyPackage and Welcome rendezvous storage, attachment cryptography, real E2EE integration, ordinary-session steering, and permission approvals remain separate reviewed milestones. diff --git a/docs/architecture/remote-e2ee-openmls.md b/docs/architecture/remote-e2ee-openmls.md new file mode 100644 index 00000000..6d0ca404 --- /dev/null +++ b/docs/architecture/remote-e2ee-openmls.md @@ -0,0 +1,313 @@ + + + +# Remote endpoint E2EE with OpenMLS + +Status: proposed for architecture and security review + +## Purpose + +This document selects the provisional endpoint-E2EE direction for Axl remote control. It replaces the earlier PQXDH plus Triple Ratchet proposal. It does not approve production dependencies, enable remote access, or make a release security claim. + +The transport and daemon boundaries remain unchanged: + +```text +remote client + -> application-level encrypted envelope + -> ciphertext-only relay + -> daemon endpoint + -> authenticated device identity + -> daemon authorization and durable acceptance + -> canonical session behavior +``` + +TLS protects each network hop. OpenMLS protects application content end to end. Successful decryption authenticates a paired device but never authorizes an operation by itself. + +## Proposed profile + +The first reviewed implementation should use one Axl-versioned profile: + +```text +profile ID: axl-e2ee-mls-pq-v1 +MLS library: OpenMLS 0.9.0, exactly pinned +crypto provider: openmls_libcrux_crypto 0.4.0, exactly pinned +cipher suite: MLS_128_MLKEM768X25519_AES256GCM_SHA384_Ed25519 +confidentiality: hybrid ML-KEM-768 and X25519 +signatures: Ed25519 +``` + +This profile provides hybrid post-quantum confidentiality and classical authentication. It does not provide post-quantum signatures. + +The IETF post-quantum MLS suite is draft material. The profile ID binds the exact draft revision, OpenMLS and provider versions, cipher suite, encoding, credential format, AAD format, storage schema, and behavior fixtures. An incompatible change requires a new Axl profile and an authenticated migration or re-pairing. An implementation must not silently reinterpret persisted state or accept a classical-only epoch. + +Production dependency addition remains blocked on a focused dependency and license review. The feasibility spike is evidence, not production code. + +## Group topology + +Axl uses one two-member group for each remote-device and daemon-installation pair: + +```text +phone A <-> daemon installation: group A +phone B <-> daemon installation: group B +browser C <-> daemon installation: group C +``` + +This topology keeps an offline or updating device from blocking another device. Revocation and re-pairing affect one pair. Axl does not initially create one group containing every remote device. + +Group membership does not grant daemon scope. Device grants remain independent daemon authority records. + +## Endpoint ownership + +Each endpoint generates and retains its private identity, signing, leaf, and group secrets. The control plane and relay receive no private cryptographic state. + +The remote device owns its replacement leaf private key. It sends a signed MLS self-Update proposal containing only public update material. The daemon must not generate or learn the remote device's replacement private key. + +The daemon is the only MLS commit creator. It validates pending proposals, may add its own update, creates one ordered commit, and persists that transition. Remote devices do not independently create competing commits. + +This rule prevents two valid successors to one epoch. Any authenticator mismatch or incompatible successor is a fork or corruption and fails closed. + +## Pairing and asynchronous establishment + +The proposed establishment flow is: + +1. The daemon creates a time-bounded pairing invitation containing non-secret identifiers and transcript commitments. +2. The remote device validates the QR invitation and binds it to the expected account, installation, daemon identity, and Axl profile. +3. The remote device creates its credential and one bounded OpenMLS KeyPackage. +4. The control plane stores the KeyPackage as opaque bounded bytes for the intended installation. +5. The daemon consumes the intended KeyPackage once, creates the pairwise group, and produces a Welcome. +6. The Welcome is delivered as opaque ciphertext through the approved rendezvous or relay path. +7. Both endpoints verify the pairing transcript, group identity, peer credential, and profile before activating the pair. +8. The daemon creates the local grant. Hosted state may narrow that grant. + +The final RFC must define invitation expiry, possession proof, one-use reservation, simultaneous claims, transcript encoding, device naming, reset, and user-visible comparison or confirmation. Account authentication alone cannot complete pairing. + +## Epoch transition + +An active pair follows this state machine: + +```text +ACTIVE(E) + -> DRAINING(E) + -> COMMIT_PERSISTED(E -> E+1) + -> WAITING_FOR_EPOCH_READY(E+1) + -> ACTIVE(E+1) +``` + +Rules: + +1. Stop accepting new old-epoch mutations for that pair. +2. Drain old-epoch mutations through daemon acceptance or an explicit terminal state. +3. Commit local MLS state advancement and exact commit bytes in one transaction. +4. Send the exact persisted commit bytes. +5. The remote endpoint applies and persists the commit atomically. +6. The remote endpoint sends an encrypted `epoch-ready` receipt binding the profile, group, commit ID, target epoch, and epoch authenticator. +7. The daemon compares the expected authenticator before enabling new-epoch application sends. + +A lost commit causes byte-identical retransmission. A lost receipt causes the receiver to recognize the already-applied commit and resend the receipt without applying the commit twice. Application ciphertext for epoch `E+1` must not overtake its commit barrier. + +Updates are serialized per pairwise group, not under a global lock. + +## Update policy + +Application messages use the MLS secret tree. The epoch encryption secret derives a sender-specific leaf secret, which feeds separate handshake and application hash ratchets. Each ratchet generation derives a one-use key and nonce and then advances one way. This chain-like symmetric ratchet is not Signal's Double Ratchet and does not perform X25519 or ML-KEM for each message. ML-KEM runs during pairing and update commits. + +The first policy should trigger a hybrid update at the earliest of: + +- approximately 24 hours while both endpoints are reachable +- 1,000 application messages +- a significant reconnect +- a membership, credential, or revocation event +- suspected state exposure +- before a sensitive operation when policy requires fresh recovery + +Routine updates wait while that device is offline. On reconnect, the pair resumes its existing valid epoch, drains accepted work, and performs a required hybrid update before policy-marked sensitive actions. + +Low-power or background operation may defer a routine update. It must not downgrade the suite. A security-required action remains blocked until the update completes. + +These thresholds are provisional and require real mobile battery, thermal, and latency measurements. + +## Transactional state and exact retries + +Every state-advancing send follows one logical transaction: + +```text +BEGIN + persist next OpenMLS state + insert exact ciphertext and stable logical destination into outbox +COMMIT +``` + +Network transmission begins only after commit. A rollback invalidates the in-memory group object; the endpoint reloads committed state before another operation. + +A retry reuses the exact stored ciphertext and encrypted request identity. It creates a new relay transport attempt ID and resolves the peer's current ephemeral route at attempt time. Durable cryptographic or outbox state must not retain an ephemeral relay route as the destination identity. + +Receive-side replay state and durable accepted-message identity advance together before plaintext is released to daemon authorization or client projection. + +The committed next state contains the next sender-ratchet generation and must not retain the used message key. The outbox stores ciphertext, not that key. Logical deletion inside the serialized MLS state is insufficient if an older plaintext state remains recoverable from SQLite pages, a write-ahead log, temporary files, crash dumps, backups, or platform snapshots. The storage review must therefore define the exact at-rest encryption and cryptographic-erasure boundary, test rollback and forensic remnants, and state which snapshot or backup attackers are outside the claim. Static full-database encryption alone does not erase an old message secret from stale database pages when the same database key can still decrypt them. + +The production adapter must not expose mutable `MlsGroup` internals. It should expose transaction-oriented operations that return immutable prepared envelopes and typed outcomes. + +## Delivery meanings + +Relay receipts do not prove endpoint or daemon acceptance: + +```text +admitted: relay accepted a bounded frame +forwarded: relay enqueued it toward the current destination route +daemon_accepted: daemon decrypted, authenticated, authorized, and durably accepted the request +``` + +Only `daemon_accepted` permits removal of a mutation from durable retry storage. Non-mutating event delivery continues to use canonical cursor and snapshot recovery after the endpoint synchronizes its epoch. + +There is no durable cloud command mailbox. A disconnected remote client retains drafts or its approved local encrypted outbox. The relay stores only bounded in-memory queues. + +## Associated data + +OpenMLS authenticated data is per message and must be set explicitly before every outgoing message. The exact canonical encoding remains a security-profile decision. + +It must bind at least: + +- Axl E2EE profile and envelope version +- Pairwise group or crypto-session identifier +- Source and destination device identifiers +- Installation identifier +- Message class +- Stable request, event, proposal, commit, or receipt identifier +- Current hosted authorization generation where applicable + +Transport attempt ID and ephemeral route ID must not enter cryptographic message identity because retries and reconnects change them. + +## Epoch tolerance and bounds + +Initial review targets are: + +```text +previous epochs retained receive-only: 2 +previous-epoch maximum age: 5 minutes +future epochs buffered: 1 +future messages: 32 +future bytes: 512 KiB +future wait: 10 seconds +``` + +These values are provisional until deterministic failure tests and load measurements approve them. All queues have count, byte, and time bounds. + +Past epochs are delivery tolerance only. Current device revocation, grant generation, daemon policy, request replay checks, and idempotency still apply after decryption. + +Too-old, too-far-future, missing-commit, suite-mismatch, and authenticator-mismatch cases fail with bounded typed errors. They trigger explicit resynchronization or re-pairing, never a silent reset or downgrade. + +## Authorization boundary + +After a successful open, the daemon performs: + +1. Map authenticated MLS credential and group to one paired device record. +2. Validate the plaintext protocol request. +3. Load current local grant, hosted narrowing generation, and terminal revocation state. +4. Require the RPC's explicit remote scope. +5. Enforce current session, sandbox, and policy constraints. +6. Apply durable command idempotency. +7. Record durable acceptance before the effect. +8. Execute through the existing daemon dispatcher. + +The device cannot supply or override its authenticated identity in plaintext. A hosted grant can only narrow local authority. Revocation overrides previous-epoch decryptability. + +## Platform boundary + +One independent Rust core is proposed for protocol state transitions and shared behavior. It must remain transport-independent and expose thin adapters for: + +- Node on the daemon +- Browser/WASM for hosted remote web +- Swift on iOS +- Kotlin/JNI on Android + +Node, browser, Swift, and Kotlin code must not independently implement MLS rules. Shared cross-platform fixtures must prove compatible messages, persistence, errors, and update behavior. + +Browser/WASM is a pre-implementation feasibility gate. The review must prove a secure random source, supported libcrux/OpenMLS target, protected device identity, and a durable transaction spanning MLS state plus exact ciphertext. A browser implementation must not be assumed from native compilation results. If this gate fails, remote web remains disabled while the architecture is reconsidered. + +Native endpoints should use platform secure storage for identity-wrapping keys and an approved transactional local database for group state and outbox data. Loss or rollback of protected identity or unrecoverable group state requires explicit re-pairing. + +## Security claims and non-claims + +The proposed profile is intended to provide: + +- End-to-end confidentiality and integrity against the relay and control plane +- Unique MLS application-message keys and deletion of used secrets +- Forward secrecy for past message keys that are erased from live and recoverable persisted state under the approved storage threat model +- Classical post-compromise recovery after a successful X25519-bearing update when the attacker has lost endpoint access +- Post-quantum confidentiality recovery after a successful ML-KEM-bearing update when the attacker has lost endpoint access +- Replay rejection and explicit fork detection + +It does not claim: + +- Signal wire compatibility +- Triple Ratchet or SPQR behavior +- Per-message public-key ratcheting +- Post-quantum authentication +- Recovery while malware still controls an endpoint +- Recovery of a stolen durable device identity without revocation and re-pairing +- Protection from plaintext endpoints +- Production security before review and independent assurance + +## Dependency and provenance gates + +Before production adoption: + +1. Pin every Rust dependency and toolchain input. +2. Review complete transitive licenses and MPL obligations. +3. Run `cargo audit` and `cargo deny` under CI. +4. Produce an SBOM and preserve notices. +5. Confirm PQ path and provider maintenance expectations with upstream maintainers. +6. Review side-channel posture for target platforms. +7. Record the upstream audit commit and excluded provider/storage scope. +8. Add fuzzing, known-answer fixtures, negative fixtures, and storage fault injection. +9. Obtain independent review of this profile and the Axl wrapper. + +AGPL-only libsignal and SPQR implementations must not be linked, copied, translated, vendored, or added to the lockfile. Public specifications may inform an independently reviewed implementation only under the repository's provenance rules. + +## Implementation gates + +### Gate A: profile approval + +Approve exact dependencies, profile, AAD, identity, pairing, storage, browser, migration, and security claims. + +### Gate B: transport-independent core + +Two fixture endpoints pair, exchange messages, reject replays, perform a daemon-created hybrid update, detect a fork, and survive deterministic loss and duplication. + +### Gate C: crash-safe persistence + +Fault injection around every write proves no state/ciphertext split, no key reuse, exact retries, and mandatory reload after rollback. + +### Gate D: platform interoperability + +Node, browser/WASM, Swift, and Kotlin run the same positive and negative fixtures. Representative phones pass latency, battery, thermal, background, and secure-storage tests. + +### Gate E: hosted integration + +The reviewed adapter replaces fake E2EE through the real control plane and relay. Pairing, route replacement, restart, revocation, commits, receipts, and daemon idempotency pass end to end. + +### Gate F: safety and assurance + +Observer access, steering, and then remote `allow_once` approval pass separate authorization gates and independent security review. No earlier gate authorizes user release. + +## Open review decisions + +The security-profile review must resolve: + +- Exact draft revision and code-point binding +- Credential encoding and identity proof +- QR transcript and confirmation UX +- KeyPackage reservation, expiry, and deletion +- Welcome transport and expiry +- Canonical AAD encoding +- Commit ID construction +- Epoch-ready receipt schema +- Storage schema, rollback detection, and cryptographic erasure of stale state pages and logs +- Browser/WASM transactional storage +- Secure-storage APIs, crash-dump behavior, snapshot exclusions, and backup policy +- Profile migration versus mandatory re-pairing +- Final epoch-retention limits +- Mobile update thresholds +- Attachment key schedule and chunk format + +Until those decisions are approved, the implementation remains behind fake E2EE and ordinary sessions remain unavailable remotely. From c88e8650c8019ae83dff6642c626964d3b2ce912 Mon Sep 17 00:00:00 2001 From: VishnuM049 Date: Tue, 15 Sep 2026 01:56:16 +0530 Subject: [PATCH 14/16] fix(sdk): recover uncertain outbox sends on startup Signed-off-by: VishnuM049 --- packages/sdk/src/remote-relay.ts | 3 ++ packages/sdk/test/remote-relay.test.ts | 45 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/packages/sdk/src/remote-relay.ts b/packages/sdk/src/remote-relay.ts index ac4078ab..8f0be0e0 100644 --- a/packages/sdk/src/remote-relay.ts +++ b/packages/sdk/src/remote-relay.ts @@ -729,6 +729,9 @@ export class RemoteHostedDelivery { async start(): Promise { if (this.started) return; this.started = true; + // A process can stop after persisting `sending` but before receiving acceptance. + // No live transport attempt survives startup, so every such record is retryable. + await this.options.outbox.resetSendingAfterDisconnect(); await this.options.connection.start(); await this.flush(); } diff --git a/packages/sdk/test/remote-relay.test.ts b/packages/sdk/test/remote-relay.test.ts index 674b3c70..1cb41825 100644 --- a/packages/sdk/test/remote-relay.test.ts +++ b/packages/sdk/test/remote-relay.test.ts @@ -248,6 +248,51 @@ test("admits with a bounded first binary message and tracks route replacement", connection.close(); }); +test("startup retries a durable sending record with byte-identical ciphertext", async () => { + const factory = new FakeSocketFactory(); + const { connection, socket } = await connect(factory); + const store = new MemoryOutboxStore(); + const opaqueEnvelope = Uint8Array.of(0, 1, 2, 255); + store.records.set(requestId, { + requestId, + idempotencyKey, + destinationCryptoSessionId: cryptoSessionId, + opaqueEnvelope, + createdAt: 1_900_000_000_000, + state: "sending", + }); + let attempt = 0; + const attemptIds = { + create() { + attempt += 1; + return parseTransportAttemptId( + `88888888-8888-4888-8888-${attempt.toString().padStart(12, "0")}`, + ); + }, + }; + const outbox = new OpaqueOutbox(store, attemptIds, connection); + const delivery = new RemoteHostedDelivery({ + connection, + outbox, + expectedDaemonId: daemonId, + attemptIds, + opener: { + async open(ciphertext) { + return { authenticatedPeerId: daemonId, plaintext: ciphertext }; + }, + }, + }); + + await delivery.start(); + + const retried = parseRelayBinaryFrame(socket.sent.at(-1) ?? new Uint8Array()); + assert.ok("destinationRouteId" in retried); + assert.equal(retried.destinationRouteId, firstDaemonRoute); + assert.deepEqual(retried.opaquePayload, opaqueEnvelope); + assert.equal((await outbox.list())[0]?.state, "sending"); + delivery.close(); +}); + test("reconnect resolves a new route and retries byte-identical prepared ciphertext", async () => { const factory = new FakeSocketFactory(); const { connection, socket: firstSocket } = await connect(factory); From 87c54aa4ad7d9fefd7047ecd05f9fa0ed77af330 Mon Sep 17 00:00:00 2001 From: VishnuM049 Date: Tue, 15 Sep 2026 02:24:43 +0530 Subject: [PATCH 15/16] fix(sdk): recover relay startup and enforce limits Signed-off-by: VishnuM049 --- packages/sdk/src/remote-relay.ts | 139 +++++++++++++++++------ packages/sdk/test/remote-relay.test.ts | 151 ++++++++++++++++++++++++- 2 files changed, 250 insertions(+), 40 deletions(-) diff --git a/packages/sdk/src/remote-relay.ts b/packages/sdk/src/remote-relay.ts index 8f0be0e0..1d831700 100644 --- a/packages/sdk/src/remote-relay.ts +++ b/packages/sdk/src/remote-relay.ts @@ -216,7 +216,8 @@ export type RemoteRelayErrorCode = | "connection_closed" | "daemon_offline" | "wrong_destination" - | "bad_relay_message"; + | "bad_relay_message" + | "frame_too_large"; export class RemoteRelayError extends Error { readonly code: RemoteRelayErrorCode; @@ -251,23 +252,34 @@ function reconnectPolicy(value: Partial = {}): RemoteReco return policy; } -async function messageBytes(value: unknown): Promise { - if (value instanceof Uint8Array) return value; - if (value instanceof ArrayBuffer) return new Uint8Array(value); +function rejectOversizedMessage(): never { + throw new RemoteRelayError("frame_too_large", "Relay message exceeds the negotiated frame limit"); +} + +function boundedBytes(bytes: Uint8Array, maximumBytes: number): Uint8Array { + if (bytes.byteLength > maximumBytes) rejectOversizedMessage(); + return bytes; +} + +async function messageBytes(value: unknown, maximumBytes: number): Promise { + if (value instanceof Uint8Array) return boundedBytes(value, maximumBytes); + if (value instanceof ArrayBuffer) { + if (value.byteLength > maximumBytes) rejectOversizedMessage(); + return new Uint8Array(value); + } if (ArrayBuffer.isView(value)) { + if (value.byteLength > maximumBytes) rejectOversizedMessage(); return new Uint8Array(value.buffer, value.byteOffset, value.byteLength).slice(); } - if ( - typeof value === "object" && - value !== null && - "arrayBuffer" in value && - typeof value.arrayBuffer === "function" - ) { - const buffer = await (value as { arrayBuffer(): Promise }).arrayBuffer(); - return new Uint8Array(buffer); + if (typeof Blob !== "undefined" && value instanceof Blob) { + if (value.size > maximumBytes) rejectOversizedMessage(); + return boundedBytes(new Uint8Array(await value.arrayBuffer()), maximumBytes); } - if (typeof value === "string") return new TextEncoder().encode(value); - throw new RemoteRelayError("bad_relay_message", "Relay message is not binary data"); + if (typeof value === "string") { + if (value.length > maximumBytes) rejectOversizedMessage(); + return boundedBytes(new TextEncoder().encode(value), maximumBytes); + } + throw new RemoteRelayError("bad_relay_message", "Relay message is not supported binary data"); } function isRelayFrame(bytes: Uint8Array): boolean { @@ -320,7 +332,9 @@ export class RemoteRelayConnection { private peers = new Map(); private generation = 0; private stopped = true; + private starting: Promise | undefined; private reconnecting: Promise | undefined; + private activeMaxFrameBytes: number | undefined; private currentState: RemoteRelayConnectionState = "disconnected"; constructor(options: RemoteRelayConnectionOptions) { @@ -346,10 +360,17 @@ export class RemoteRelayConnection { return this.sourceRoute; } - async start(): Promise { - if (!this.stopped) return; + start(): Promise { + if (this.currentState === "connected") return Promise.resolve(); + if (this.starting !== undefined) return this.starting; + if (this.reconnecting !== undefined) return this.reconnecting; this.stopped = false; - await this.connectWithRetry("connecting"); + const operation = this.connectWithRetry("connecting"); + const starting = operation.finally(() => { + if (this.starting === starting) this.starting = undefined; + }); + this.starting = starting; + return starting; } close(): void { @@ -358,6 +379,7 @@ export class RemoteRelayConnection { this.generation += 1; this.socket?.close(1000, "client_closed"); this.socket = undefined; + this.activeMaxFrameBytes = undefined; this.clearRoutes(); this.rejectRouteWaiters( new RemoteRelayError("connection_closed", "Relay connection is closed"), @@ -425,14 +447,23 @@ export class RemoteRelayConnection { if (this.currentState !== "connected" || socket === undefined || socket.readyState !== 1) { throw new RemoteRelayError("connection_closed", "Relay connection is not connected"); } - socket.send( - encodeRelayBinaryFrame({ - transportVersion: REMOTE_TRANSPORT_VERSION, - attemptId, - destinationRouteId, - opaquePayload: payload, - }), - ); + const maximumBytes = this.activeMaxFrameBytes; + if (maximumBytes === undefined) { + throw new RemoteRelayError("connection_closed", "Relay connection has no active limits"); + } + const frame = encodeRelayBinaryFrame({ + transportVersion: REMOTE_TRANSPORT_VERSION, + attemptId, + destinationRouteId, + opaquePayload: payload, + }); + if (frame.byteLength > maximumBytes) { + throw new RemoteRelayError( + "frame_too_large", + "Relay frame exceeds the negotiated frame limit", + ); + } + socket.send(frame); } private async connectWithRetry(state: "connecting" | "reconnecting"): Promise { @@ -445,9 +476,11 @@ export class RemoteRelayConnection { return; } catch (error) { latest = error; + this.discardFailedSocket(); } } if (this.stopped) return; + this.stopped = true; this.setState("disconnected"); throw new RemoteRelayError("connection_failed", "Relay reconnect attempts were exhausted", { cause: latest, @@ -482,7 +515,14 @@ export class RemoteRelayConnection { ); const open: RemoteWebSocketListener = () => { try { - socket.send(admissionBytes(credential)); + const admission = admissionBytes(credential); + if (admission.byteLength > credential.limits.maxFrameBytes) { + throw new RemoteRelayError( + "frame_too_large", + "Relay admission exceeds the negotiated frame limit", + ); + } + socket.send(admission); } catch (cause) { finish( new RemoteRelayError("invalid_admission", "Could not send relay admission", { cause }), @@ -491,7 +531,7 @@ export class RemoteRelayConnection { }; const message: RemoteWebSocketListener = (event) => { if (event.type !== "message") return; - void this.handleMessage(event.data, generation) + void this.handleMessage(event.data, generation, credential.limits.maxFrameBytes) .then((snapshot) => { if (snapshot) finish(); }) @@ -522,12 +562,17 @@ export class RemoteRelayConnection { socket.close(1000, "stale_connection"); throw new RemoteRelayError("connection_closed", "Relay connection became stale"); } + this.activeMaxFrameBytes = credential.limits.maxFrameBytes; this.setState("connected"); } - private async handleMessage(value: unknown, generation: number): Promise { + private async handleMessage( + value: unknown, + generation: number, + maximumBytes: number, + ): Promise { if (generation !== this.generation || this.stopped) return false; - const bytes = await messageBytes(value); + const bytes = await messageBytes(value, maximumBytes); if (!isRelayFrame(bytes)) { let parsed: unknown; try { @@ -554,6 +599,15 @@ export class RemoteRelayConnection { return false; } + private discardFailedSocket(): void { + const socket = this.socket; + this.generation += 1; + this.socket = undefined; + this.activeMaxFrameBytes = undefined; + socket?.close(1000, "connection_attempt_failed"); + this.clearRoutes(); + } + private applyDiscovery(message: ReturnType): void { if (message.type === "route_snapshot") { this.sourceRoute = message.sourceRoute; @@ -607,6 +661,7 @@ export class RemoteRelayConnection { if (generation !== this.generation) return; const wasConnected = this.currentState === "connected"; this.socket = undefined; + this.activeMaxFrameBytes = undefined; this.clearRoutes(); this.rejectRouteWaiters(error); if (!wasConnected || this.stopped || this.reconnecting !== undefined) return; @@ -671,6 +726,7 @@ export class RemoteHostedDelivery { private flushTail: Promise = Promise.resolve(); private inboundTail: Promise = Promise.resolve(); private started = false; + private starting: Promise | undefined; constructor(options: RemoteHostedDeliveryOptions) { this.options = options; @@ -726,14 +782,25 @@ export class RemoteHostedDelivery { return () => this.errorListeners.delete(listener); } - async start(): Promise { - if (this.started) return; + start(): Promise { + if (this.started && this.starting === undefined) return Promise.resolve(); + if (this.starting !== undefined) return this.starting; this.started = true; - // A process can stop after persisting `sending` but before receiving acceptance. - // No live transport attempt survives startup, so every such record is retryable. - await this.options.outbox.resetSendingAfterDisconnect(); - await this.options.connection.start(); - await this.flush(); + const operation = (async () => { + // A process can stop after persisting `sending` but before receiving acceptance. + // No live transport attempt survives startup, so every such record is retryable. + await this.options.outbox.resetSendingAfterDisconnect(); + await this.options.connection.start(); + await this.flush(); + })().catch((error: unknown) => { + this.started = false; + throw error; + }); + const starting = operation.finally(() => { + if (this.starting === starting) this.starting = undefined; + }); + this.starting = starting; + return starting; } close(): void { diff --git a/packages/sdk/test/remote-relay.test.ts b/packages/sdk/test/remote-relay.test.ts index 1cb41825..9642769e 100644 --- a/packages/sdk/test/remote-relay.test.ts +++ b/packages/sdk/test/remote-relay.test.ts @@ -30,6 +30,7 @@ import { HttpRelayTicketProvider, RemoteHostedDelivery, RemoteRelayConnection, + RemoteRelayError, type RelayAdmissionCredential, type RemoteRelayConnectionState, type RemoteWebSocket, @@ -89,10 +90,14 @@ class FakeSocket implements RemoteWebSocket { this.emit({ type: "open" }); } - message(data: Uint8Array): void { + message(data: unknown): void { this.emit({ type: "message", data }); } + fail(): void { + this.emit({ type: "error" }); + } + private emit(event: RemoteWebSocketEvent): void { for (const listener of this.listeners.get(event.type) ?? []) listener(event); } @@ -117,13 +122,13 @@ const requestId = parseRemoteRequestId("55555555-5555-4555-8555-555555555555"); const idempotencyKey = parseIdempotencyKey("66666666-6666-4666-8666-666666666666"); const daemonId = parseDeviceId("77777777-7777-4777-8777-777777777777"); -function credential(): RelayAdmissionCredential { +function credential(maxFrameBytes = DEFAULT_RELAY_LIMITS.maxFrameBytes): RelayAdmissionCredential { return { ticket: "one-use-ticket", relayUrl: "wss://relay.invalid/v1/connect", expiresAt: Date.now() + 60_000, proofSchemeVersion: 1, - limits: DEFAULT_RELAY_LIMITS, + limits: { ...DEFAULT_RELAY_LIMITS, maxFrameBytes }, connectionNonce: "nonce", possessionProof: Uint8Array.of(1, 2, 3), }; @@ -151,11 +156,12 @@ async function nextTurn(): Promise { async function connect( factory: FakeSocketFactory, + issuedCredential = credential(), ): Promise<{ readonly connection: RemoteRelayConnection; readonly socket: FakeSocket }> { const connection = new RemoteRelayConnection({ tickets: { async acquire() { - return credential(); + return issuedCredential; }, }, sockets: factory, @@ -248,6 +254,143 @@ test("admits with a bounded first binary message and tracks route replacement", connection.close(); }); +test("failed delivery startup can retry and concurrent starts share one connection attempt", async () => { + const factory = new FakeSocketFactory(); + let acquisitions = 0; + const connection = new RemoteRelayConnection({ + tickets: { + async acquire() { + acquisitions += 1; + if (acquisitions === 1) throw new Error("control plane unavailable"); + return credential(); + }, + }, + sockets: factory, + destinationCryptoSessionId: cryptoSessionId, + reconnect: { initialDelayMs: 1, maximumDelayMs: 1, jitterRatio: 0, maximumAttempts: 1 }, + routeWaitMs: 100, + sleep: async () => undefined, + }); + const store = new MemoryOutboxStore(); + const attemptIds = { + create: () => parseTransportAttemptId("88888888-8888-4888-8888-000000000001"), + }; + const delivery = new RemoteHostedDelivery({ + connection, + outbox: new OpaqueOutbox(store, attemptIds, connection), + expectedDaemonId: daemonId, + attemptIds, + opener: { + async open(ciphertext) { + return { authenticatedPeerId: daemonId, plaintext: ciphertext }; + }, + }, + }); + + await assert.rejects(delivery.start(), /attempts were exhausted/); + assert.equal(connection.state, "disconnected"); + assert.equal(acquisitions, 1); + + const firstRetry = delivery.start(); + const concurrentRetry = delivery.start(); + assert.equal(firstRetry, concurrentRetry); + await nextTurn(); + const socket = factory.sockets[0]; + assert.ok(socket); + socket.open(); + socket.message(discovery("route_snapshot", firstDaemonRoute)); + await Promise.all([firstRetry, concurrentRetry]); + + assert.equal(acquisitions, 2); + assert.equal(connection.state, "connected"); + delivery.close(); +}); + +test("failed WebSocket startup can retry with a new ticket and socket", async () => { + const factory = new FakeSocketFactory(); + let acquisitions = 0; + const connection = new RemoteRelayConnection({ + tickets: { + async acquire() { + acquisitions += 1; + return credential(); + }, + }, + sockets: factory, + destinationCryptoSessionId: cryptoSessionId, + reconnect: { initialDelayMs: 1, maximumDelayMs: 1, jitterRatio: 0, maximumAttempts: 1 }, + routeWaitMs: 100, + sleep: async () => undefined, + }); + + const failedStart = connection.start(); + const concurrentFailedStart = connection.start(); + assert.equal(failedStart, concurrentFailedStart); + await nextTurn(); + const failedSocket = factory.sockets[0]; + assert.ok(failedSocket); + failedSocket.fail(); + await assert.rejects(failedStart, /attempts were exhausted/); + + const retry = connection.start(); + const concurrentRetry = connection.start(); + assert.equal(retry, concurrentRetry); + await nextTurn(); + const retrySocket = factory.sockets[1]; + assert.ok(retrySocket); + retrySocket.open(); + retrySocket.message(discovery("route_snapshot", firstDaemonRoute)); + await retry; + + assert.equal(acquisitions, 2); + assert.equal(connection.state, "connected"); + connection.close(); +}); + +test("enforces the negotiated frame limit before conversion and outbound send", async () => { + const maximumBytes = 512; + const factory = new FakeSocketFactory(); + const { connection, socket } = await connect(factory, credential(maximumBytes)); + const oversizedPayload = new Uint8Array(maximumBytes - 37); + + assert.throws( + () => + connection.send( + firstDaemonRoute, + parseTransportAttemptId("88888888-8888-4888-8888-000000000001"), + oversizedPayload, + ), + (error) => error instanceof RemoteRelayError && error.code === "frame_too_large", + ); + + let converted = false; + const oversizedBlob = new Blob([new Uint8Array(maximumBytes + 1)]); + Object.defineProperty(oversizedBlob, "arrayBuffer", { + value: async () => { + converted = true; + return new ArrayBuffer(maximumBytes + 1); + }, + }); + socket.message(oversizedBlob); + await nextTurn(); + + assert.equal(converted, false); + assert.equal(socket.readyState, 3); + connection.close(); +}); + +test("rejects oversized discovery text before JSON parsing", async () => { + const maximumBytes = 512; + const factory = new FakeSocketFactory(); + const { connection, socket } = await connect(factory, credential(maximumBytes)); + + socket.message("{".repeat(maximumBytes + 1)); + await nextTurn(); + + assert.equal(socket.readyState, 3); + connection.close(); +}); + test("startup retries a durable sending record with byte-identical ciphertext", async () => { const factory = new FakeSocketFactory(); const { connection, socket } = await connect(factory); From dfbf779c2391240f87a583c82ebec13b1a42e0d6 Mon Sep 17 00:00:00 2001 From: VishnuM049 Date: Tue, 15 Sep 2026 03:12:36 +0530 Subject: [PATCH 16/16] fix(sdk): cancel stale relay startup work Signed-off-by: VishnuM049 --- packages/sdk/src/remote-relay.ts | 35 +++++++--- packages/sdk/test/remote-relay.test.ts | 92 ++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 8 deletions(-) diff --git a/packages/sdk/src/remote-relay.ts b/packages/sdk/src/remote-relay.ts index 1d831700..3cb414a7 100644 --- a/packages/sdk/src/remote-relay.ts +++ b/packages/sdk/src/remote-relay.ts @@ -331,6 +331,7 @@ export class RemoteRelayConnection { private sourceRoute: RelayPeerRoute | undefined; private peers = new Map(); private generation = 0; + private lifecycleGeneration = 0; private stopped = true; private starting: Promise | undefined; private reconnecting: Promise | undefined; @@ -365,7 +366,8 @@ export class RemoteRelayConnection { if (this.starting !== undefined) return this.starting; if (this.reconnecting !== undefined) return this.reconnecting; this.stopped = false; - const operation = this.connectWithRetry("connecting"); + const lifecycleGeneration = ++this.lifecycleGeneration; + const operation = this.connectWithRetry("connecting", lifecycleGeneration); const starting = operation.finally(() => { if (this.starting === starting) this.starting = undefined; }); @@ -376,6 +378,7 @@ export class RemoteRelayConnection { close(): void { if (this.stopped && this.currentState === "closed") return; this.stopped = true; + this.lifecycleGeneration += 1; this.generation += 1; this.socket?.close(1000, "client_closed"); this.socket = undefined; @@ -466,20 +469,28 @@ export class RemoteRelayConnection { socket.send(frame); } - private async connectWithRetry(state: "connecting" | "reconnecting"): Promise { + private async connectWithRetry( + state: "connecting" | "reconnecting", + lifecycleGeneration: number, + ): Promise { + if (!this.lifecycleIsActive(lifecycleGeneration)) return; this.setState(state); let latest: unknown; - for (let attempt = 0; attempt < this.policy.maximumAttempts && !this.stopped; attempt += 1) { - if (attempt > 0) await this.sleep(this.retryDelay(attempt - 1)); + for (let attempt = 0; attempt < this.policy.maximumAttempts; attempt += 1) { + if (attempt > 0) { + await this.sleep(this.retryDelay(attempt - 1)); + if (!this.lifecycleIsActive(lifecycleGeneration)) return; + } try { - await this.connectOnce(); + await this.connectOnce(lifecycleGeneration); return; } catch (error) { latest = error; this.discardFailedSocket(); + if (!this.lifecycleIsActive(lifecycleGeneration)) return; } } - if (this.stopped) return; + if (!this.lifecycleIsActive(lifecycleGeneration)) return; this.stopped = true; this.setState("disconnected"); throw new RemoteRelayError("connection_failed", "Relay reconnect attempts were exhausted", { @@ -487,8 +498,13 @@ export class RemoteRelayConnection { }); } - private async connectOnce(): Promise { + private lifecycleIsActive(generation: number): boolean { + return !this.stopped && generation === this.lifecycleGeneration; + } + + private async connectOnce(lifecycleGeneration: number): Promise { const credential = await this.options.tickets.acquire(); + if (!this.lifecycleIsActive(lifecycleGeneration)) return; if (credential.expiresAt <= Date.now()) { throw new RemoteRelayError("invalid_admission", "Relay ticket is already expired"); } @@ -573,6 +589,7 @@ export class RemoteRelayConnection { ): Promise { if (generation !== this.generation || this.stopped) return false; const bytes = await messageBytes(value, maximumBytes); + if (generation !== this.generation || this.stopped) return false; if (!isRelayFrame(bytes)) { let parsed: unknown; try { @@ -665,7 +682,9 @@ export class RemoteRelayConnection { this.clearRoutes(); this.rejectRouteWaiters(error); if (!wasConnected || this.stopped || this.reconnecting !== undefined) return; - const reconnecting = this.connectWithRetry("reconnecting").catch(() => undefined); + const reconnecting = this.connectWithRetry("reconnecting", this.lifecycleGeneration).catch( + () => undefined, + ); this.reconnecting = reconnecting; void reconnecting.finally(() => { if (this.reconnecting === reconnecting) this.reconnecting = undefined; diff --git a/packages/sdk/test/remote-relay.test.ts b/packages/sdk/test/remote-relay.test.ts index 9642769e..34db0abe 100644 --- a/packages/sdk/test/remote-relay.test.ts +++ b/packages/sdk/test/remote-relay.test.ts @@ -154,6 +154,17 @@ async function nextTurn(): Promise { await new Promise((resolve) => setImmediate(resolve)); } +function deferred(): { + readonly promise: Promise; + readonly resolve: (value: Value) => void; +} { + let resolve!: (value: Value) => void; + const promise = new Promise((accept) => { + resolve = accept; + }); + return { promise, resolve }; +} + async function connect( factory: FakeSocketFactory, issuedCredential = credential(), @@ -347,6 +358,87 @@ test("failed WebSocket startup can retry with a new ticket and socket", async () connection.close(); }); +test("close during retry backoff prevents another ticket acquisition", async () => { + const factory = new FakeSocketFactory(); + const backoffStarted = deferred(); + const releaseBackoff = deferred(); + let acquisitions = 0; + const connection = new RemoteRelayConnection({ + tickets: { + async acquire() { + acquisitions += 1; + if (acquisitions === 1) throw new Error("temporary ticket failure"); + return credential(); + }, + }, + sockets: factory, + reconnect: { initialDelayMs: 1, maximumDelayMs: 1, jitterRatio: 0, maximumAttempts: 2 }, + sleep: async () => { + backoffStarted.resolve(); + await releaseBackoff.promise; + }, + }); + + const starting = connection.start(); + await backoffStarted.promise; + connection.close(); + releaseBackoff.resolve(); + await starting; + + assert.equal(connection.state, "closed"); + assert.equal(acquisitions, 1); + assert.equal(factory.sockets.length, 0); +}); + +test("close during ticket acquisition prevents WebSocket creation", async () => { + const factory = new FakeSocketFactory(); + const pendingCredential = deferred(); + let acquisitions = 0; + const connection = new RemoteRelayConnection({ + tickets: { + acquire() { + acquisitions += 1; + return pendingCredential.promise; + }, + }, + sockets: factory, + }); + + const starting = connection.start(); + await nextTurn(); + connection.close(); + pendingCredential.resolve(credential()); + await starting; + + assert.equal(connection.state, "closed"); + assert.equal(acquisitions, 1); + assert.equal(factory.sockets.length, 0); +}); + +test("close while converting a message prevents stale route application", async () => { + const factory = new FakeSocketFactory(); + const { connection, socket } = await connect(factory); + const pendingBytes = deferred(); + const delayedBlob = new Blob([Uint8Array.of(1)]); + Object.defineProperty(delayedBlob, "arrayBuffer", { value: () => pendingBytes.promise }); + + socket.message(delayedBlob); + await nextTurn(); + connection.close(); + const available = discovery("route_available", secondDaemonRoute); + pendingBytes.resolve( + available.buffer.slice( + available.byteOffset, + available.byteOffset + available.byteLength, + ) as ArrayBuffer, + ); + await nextTurn(); + await nextTurn(); + + assert.equal(connection.state, "closed"); + assert.deepEqual(connection.routes, []); +}); + test("enforces the negotiated frame limit before conversion and outbound send", async () => { const maximumBytes = 512; const factory = new FakeSocketFactory();