From fc2df9d372aa2e077440b301e797a466c6711bff Mon Sep 17 00:00:00 2001 From: w Date: Sat, 26 Sep 2026 17:57:42 -0400 Subject: [PATCH 01/36] feat(truapi): add the PeerTransport host service Adds TrUAPI trait 21, PeerTransport: host-terminated JAMNP-S streams to JAM peers (dial/open/send/recv/reset/close/events). The host terminates QUIC or WebTransport, builds the jamnp-s ALPN from the declared genesis and pins the peer's certificate identity; the guest verifies every byte it receives. A host may grant it only to an execution whose App manifest v2 declares capabilities.network.jam = { genesis }, for that genesis only, with bounded connections, streams and message sizes. The default implementation, including the Rust product runtime, returns NotGranted. Ships the browser WebTransport session and the deterministic PolkaJAM certificate-hash derivation under @parity/truapi/peer-transport, with SCALE codec vectors pinned against the Rust types. --- .changeset/pvm-peer-transport.md | 10 + js/packages/truapi/package.json | 4 + .../truapi/src/peer-transport-cert.test.ts | 101 ++++ js/packages/truapi/src/peer-transport-cert.ts | 201 +++++++ .../truapi/src/peer-transport-wire.test.ts | 91 ++++ js/packages/truapi/src/peer-transport.test.ts | 281 ++++++++++ js/packages/truapi/src/peer-transport.ts | 515 ++++++++++++++++++ rust/crates/truapi-client/src/generated.rs | 212 ++++++- rust/crates/truapi-codegen/src/rust.rs | 1 + rust/crates/truapi-server/src/core.rs | 46 ++ rust/crates/truapi-server/src/lib.rs | 1 + .../truapi-server/src/peer_transport.rs | 128 +++++ .../src/runtime/capabilities/resources.rs | 4 + .../tests/peer_transport_grant.rs | 207 +++++++ rust/crates/truapi/src/api.rs | 4 + rust/crates/truapi/src/api/peer_transport.rs | 149 +++++ rust/crates/truapi/src/lib.rs | 28 +- rust/crates/truapi/src/v01.rs | 2 + rust/crates/truapi/src/v01/peer_transport.rs | 191 +++++++ rust/crates/truapi/src/versioned.rs | 1 + .../truapi/src/versioned/peer_transport.rs | 27 + 21 files changed, 2193 insertions(+), 11 deletions(-) create mode 100644 .changeset/pvm-peer-transport.md create mode 100644 js/packages/truapi/src/peer-transport-cert.test.ts create mode 100644 js/packages/truapi/src/peer-transport-cert.ts create mode 100644 js/packages/truapi/src/peer-transport-wire.test.ts create mode 100644 js/packages/truapi/src/peer-transport.test.ts create mode 100644 js/packages/truapi/src/peer-transport.ts create mode 100644 rust/crates/truapi-server/src/peer_transport.rs create mode 100644 rust/crates/truapi-server/tests/peer_transport_grant.rs create mode 100644 rust/crates/truapi/src/api/peer_transport.rs create mode 100644 rust/crates/truapi/src/v01/peer_transport.rs create mode 100644 rust/crates/truapi/src/versioned/peer_transport.rs diff --git a/.changeset/pvm-peer-transport.md b/.changeset/pvm-peer-transport.md new file mode 100644 index 0000000000..a9253fc589 --- /dev/null +++ b/.changeset/pvm-peer-transport.md @@ -0,0 +1,10 @@ +--- +"@parity/truapi": minor +"@parity/truapi-host": minor +--- + +Add the `PeerTransport` host service (trait 21): host-terminated JAMNP-S QUIC or WebTransport streams to JAM peers +with `dial`, `open`, `send`, `recv`, `reset`, `close` and `events`. A host may grant it only to an execution whose +App manifest (`$v` 2) declares `capabilities.network.jam = { genesis }`, and only for that genesis; the default +implementation, including the Rust product runtime, returns `NotGranted`. Ships the browser WebTransport adapter and +the deterministic PolkaJAM certificate-hash derivation under `@parity/truapi/peer-transport`. diff --git a/js/packages/truapi/package.json b/js/packages/truapi/package.json index 851e67bb7d..57d54abe18 100644 --- a/js/packages/truapi/package.json +++ b/js/packages/truapi/package.json @@ -35,6 +35,10 @@ "types": "./dist/internal.d.ts", "import": "./dist/internal.js" }, + "./peer-transport": { + "types": "./dist/peer-transport.d.ts", + "import": "./dist/peer-transport.js" + }, "./scale": { "types": "./dist/scale.d.ts", "import": "./dist/scale.js" diff --git a/js/packages/truapi/src/peer-transport-cert.test.ts b/js/packages/truapi/src/peer-transport-cert.test.ts new file mode 100644 index 0000000000..186a0b4513 --- /dev/null +++ b/js/packages/truapi/src/peer-transport-cert.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; +import { + decompressP256, + ed25519IdToKey, + p256IdToCompressed, + peerIdText, + validityBounds, + validityPeriodAt, + webTransportCertificateDer, + webTransportCertificateHash, + webTransportCertificateHashes, +} from "./peer-transport-cert.js"; + +// Vectors produced by rcgen 0.14.8 / p256 0.13.2 (the PolkaJAM dd9af78 +// lockfile versions) following PolkaJAM's `net/cert.rs`, at unix time +// 1790380800. +const VECTORS = [ + { + id: "vie5obg5rgcfrtqgw2vm37t7l4mssjdncce33wdf5tfndfjqsg6ba", + compressed: "028874174c8f469438a1b1bab2fde75f9c4999461382ec6d47e9b3b4511294c607", + hashes: { + 2071: "ccf30196b29007b42fca6f406363ce17781bab0f011bac47dcbe307e0e6a316d", + 2072: "eb09b6b027f5953cb8ca2e8f296e21052c3423370876e67f1634180ddf99f1ec", + 2073: "8bdfa3a2b7822822f5da33fadfa118d39d05b0a6b1086fc82a62a2209f6fae27", + }, + der2072: + "3082013d3081f0a003020102020100300506032b6570300e310c300a06035504030c036a616d301e170d3236303932333030303030305a170d3236313030353030303030305a300e310c300a06035504030c036a616d3059301306072a8648ce3d020106082a8648ce3d030107034200048874174c8f469438a1b1bab2fde75f9c4999461382ec6d47e9b3b4511294c6073947ad8ea69d98ff2844bb02f2231ceb65fadc22aa4f529b602182f6ab5ec924a344304230400603551d11043930378235766965356f62673572676366727471677732766d333774376c346d73736a646e63636533337764663574666e64666a717367366261300506032b657003410000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + }, + { + id: "o5edmn6gwjzmsyahsffsiu4kp3ao6ufexbp2tpzj2jhuplxbb3dxb", + compressed: "039d0cd6bcb1293389c191a54844b97a1b384f0bb9e1e9f972d2e9d0b76e087bdc", + hashes: { + 2071: "13589df87a7a37dd3c800d2a724568d0e5d049e27655cb1d8b6a492268be7bb4", + 2072: "2d25e5bf1695ee00c9a197cef8dfa3daa5ee9d6ddf905eebe3bdf58f464e19cd", + 2073: "1798ee46ac715588b3df8561f85f9717497755eac87077439bc0c84a37443e26", + }, + }, +] as const; + +describe("PolkaJAM peer id text", () => { + test("decodes P-256 ids to the compressed point and back", () => { + for (const vector of VECTORS) { + const compressed = p256IdToCompressed(vector.id); + expect(bytesToHex(compressed)).toBe(vector.compressed); + expect(peerIdText(vector.id[0]!, compressed.subarray(1))).toBe(vector.id); + } + }); + + test("decodes Ed25519 ids and rejects the wrong prefix", () => { + const key = ed25519IdToKey("e5ayk2kkzlxdvih2pud5ndhb4qtj2ub4hnpkwmonlma4i55xm6wra"); + expect(peerIdText("e", key)).toBe("e5ayk2kkzlxdvih2pud5ndhb4qtj2ub4hnpkwmonlma4i55xm6wra"); + expect(() => ed25519IdToKey(VECTORS[0].id)).toThrow("begin with 'e'"); + expect(() => p256IdToCompressed("e5ayk2kkzlxdvih2pud5ndhb4qtj2ub4hnpkwmonlma4i55xm6wra")).toThrow( + "'o' or 'v'", + ); + }); +}); + +describe("P-256 decompression", () => { + test("matches the uncompressed point rcgen embedded", () => { + const point = decompressP256(hexToBytes(VECTORS[0].compressed)); + // The SPKI BIT STRING in der2072 carries 0x04 ‖ x ‖ y. + const index = VECTORS[0].der2072.indexOf("03420004") + 6; + expect(bytesToHex(point)).toBe(VECTORS[0].der2072.slice(index, index + 130)); + }); + + test("rejects off-curve x", () => { + const bad = hexToBytes(VECTORS[0].compressed); + bad[32] ^= 1; + expect(() => decompressP256(bad)).toThrow("not on the curve"); + }); +}); + +describe("validity periods", () => { + test("splits time into padded 10-day windows", () => { + expect(validityPeriodAt(1_790_380_800)).toBe(2072); + expect(validityBounds(2072)).toEqual([2072 * 864_000 - 86_400, 2073 * 864_000 + 86_400]); + }); +}); + +describe("certificate derivation", () => { + test("reproduces the rcgen DER byte for byte", () => { + const der = webTransportCertificateDer(hexToBytes(VECTORS[0].compressed), 2072); + expect(bytesToHex(der)).toBe(VECTORS[0].der2072); + }); + + test("hashes match the Rust cross-check for both y parities", () => { + for (const vector of VECTORS) { + const compressed = hexToBytes(vector.compressed); + for (const [period, hash] of Object.entries(vector.hashes)) { + expect(bytesToHex(webTransportCertificateHash(compressed, Number(period)))).toBe(hash); + } + expect(webTransportCertificateHashes(compressed, 1_790_380_800).map(bytesToHex)).toEqual([ + vector.hashes[2071], + vector.hashes[2072], + vector.hashes[2073], + ]); + } + }); +}); diff --git a/js/packages/truapi/src/peer-transport-cert.ts b/js/packages/truapi/src/peer-transport-cert.ts new file mode 100644 index 0000000000..ed052f9e11 --- /dev/null +++ b/js/packages/truapi/src/peer-transport-cert.ts @@ -0,0 +1,201 @@ +import { sha256 } from "@noble/hashes/sha2.js"; + +/** + * Deterministic WebTransport certificate hashes for a PolkaJAM peer. + * + * PolkaJAM (`crates/node/src/net/cert.rs`, `dd9af78`) serves an unsigned X.509 + * certificate for its P-256 peer key: serial 0, issuer and subject `CN=jam`, + * one dNSName SAN equal to the peer-id text, Ed25519 signature algorithm with + * an all-zero 64-byte signature, and a validity window derived from a fixed + * 10-day period padded by one day on both sides. A client that knows the + * peer's compressed P-256 key can therefore compute the certificate hashes + * offline and pass them as `serverCertificateHashes`. These bytes mirror + * PolkaJAM's `crates/node/src/net/cert.rs` generated with rcgen 0.14.8. + */ + +export const UNPADDED_VALIDITY_PERIOD_SECS = 10 * 24 * 3600; +export const VALIDITY_PERIOD_PADDING_SECS = 24 * 3600; + +const BITS_TO_CHAR = "abcdefghijklmnopqrstuvwxyz234567"; +const P = (1n << 256n) - (1n << 224n) + (1n << 192n) + (1n << 96n) - 1n; +const B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn; +const OID_ED25519 = [0x06, 0x03, 0x2b, 0x65, 0x70]; +const OID_EC_PUBLIC_KEY = [0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01]; +const OID_PRIME256V1 = [0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07]; +const OID_SUBJECT_ALT_NAME = [0x06, 0x03, 0x55, 0x1d, 0x11]; +const ascii = new TextEncoder(); + +/** PolkaJAM peer-id text: prefix letter then 32 bytes, base-32 LSB-first. */ +export function peerIdText(prefix: string, bytes: Uint8Array): string { + if (bytes.length !== 32) throw new Error("peer id needs 32 bytes"); + let text = prefix; + for (let i = 0; i < 256; i += 5) { + const low = bytes[i >> 3]!; + const high = bytes[(i >> 3) + 1] ?? 0; + text += BITS_TO_CHAR[((low | (high << 8)) >> (i % 8)) & 0x1f]; + } + return text; +} + +/** Parse `e…`, `o…` or `v…` text into (prefix, 32 bytes). */ +export function parsePeerIdText(text: string): { prefix: string; bytes: Uint8Array } { + if (text.length !== 53) throw new Error(`peer id text must be 53 characters, got ${text.length}`); + const bytes = new Uint8Array(32); + let acc = 0; + let n = 0; + let i = 0; + for (const char of text.slice(1)) { + const bits = BITS_TO_CHAR.indexOf(char); + if (bits < 0) throw new Error(`invalid peer id character ${JSON.stringify(char)}`); + acc |= bits << n; + n += 5; + if (n >= 8) { + bytes[i++] = acc & 0xff; + acc >>= 8; + n -= 8; + } + } + if (acc !== 0) throw new Error("peer id has non-zero trailing bits"); + return { prefix: text[0]!, bytes }; +} + +/** `o…`/`v…` text to a compressed SEC1 P-256 point (0x03 odd y / 0x02 even y). */ +export function p256IdToCompressed(text: string): Uint8Array { + const { prefix, bytes } = parsePeerIdText(text); + if (prefix !== "o" && prefix !== "v") throw new Error("P-256 peer ids begin with 'o' or 'v'"); + const out = new Uint8Array(33); + out[0] = prefix === "o" ? 3 : 2; + out.set(bytes, 1); + return out; +} + +/** Ed25519 `e…` text to the 32-byte public key. */ +export function ed25519IdToKey(text: string): Uint8Array { + const { prefix, bytes } = parsePeerIdText(text); + if (prefix !== "e") throw new Error("Ed25519 peer ids begin with 'e'"); + return bytes; +} + +function bigintFromBytes(bytes: Uint8Array): bigint { + let v = 0n; + for (const b of bytes) v = (v << 8n) | BigInt(b); + return v; +} + +function bytesFromBigint(v: bigint, length: number): Uint8Array { + const out = new Uint8Array(length); + for (let i = length - 1; i >= 0; i--) { + out[i] = Number(v & 0xffn); + v >>= 8n; + } + return out; +} + +function modPow(base: bigint, exp: bigint, mod: bigint): bigint { + let result = 1n; + base %= mod; + while (exp > 0n) { + if (exp & 1n) result = (result * base) % mod; + base = (base * base) % mod; + exp >>= 1n; + } + return result; +} + +/** Uncompressed SEC1 (0x04 ‖ x ‖ y) for a compressed P-256 point; p ≡ 3 mod 4. */ +export function decompressP256(compressed: Uint8Array): Uint8Array { + if (compressed.length !== 33 || (compressed[0] !== 2 && compressed[0] !== 3)) { + throw new Error("expected a 33-byte compressed P-256 point"); + } + const x = bigintFromBytes(compressed.subarray(1)); + if (x >= P) throw new Error("P-256 x coordinate out of range"); + const rhs = (((x * x) % P) * x - 3n * x + B) % P; + const alpha = (rhs + P) % P; + let y = modPow(alpha, (P + 1n) >> 2n, P); + if ((y * y) % P !== alpha) throw new Error("P-256 x coordinate is not on the curve"); + if ((y & 1n) !== BigInt(compressed[0]! & 1)) y = P - y; + const out = new Uint8Array(65); + out[0] = 4; + out.set(bytesFromBigint(x, 32), 1); + out.set(bytesFromBigint(y, 32), 33); + return out; +} + +function der(tag: number, ...parts: ArrayLike[]): number[] { + const body = parts.flatMap((part) => Array.from(part)); + const len = body.length; + const header = + len < 0x80 + ? [tag, len] + : len < 0x100 + ? [tag, 0x81, len] + : [tag, 0x82, len >> 8, len & 0xff]; + return header.concat(body); +} + +function utcTime(unixSecs: number): number[] { + const date = new Date(unixSecs * 1000); + const year = date.getUTCFullYear(); + if (year < 1950 || year >= 2050) { + throw new Error("validity outside the UTCTime range PolkaJAM certificates use"); + } + const two = (n: number): string => String(n).padStart(2, "0"); + const text = `${two(year % 100)}${two(date.getUTCMonth() + 1)}${two(date.getUTCDate())}${two( + date.getUTCHours(), + )}${two(date.getUTCMinutes())}${two(date.getUTCSeconds())}Z`; + return der(0x17, ascii.encode(text)); +} + +const JAM_DN = der(0x30, der(0x31, der(0x30, [0x06, 0x03, 0x55, 0x04, 0x03], der(0x0c, ascii.encode("jam"))))); +const ED25519_ALG = der(0x30, OID_ED25519); + +/** Fixed 10-day period index for a unix time; the server switches at boundaries. */ +export function validityPeriodAt(unixSecs: number): number { + return Math.floor(unixSecs / UNPADDED_VALIDITY_PERIOD_SECS); +} + +/** `[notBefore, notAfter]` unix seconds of a period (padded by one day). */ +export function validityBounds(period: number): [number, number] { + return [ + Math.max(period * UNPADDED_VALIDITY_PERIOD_SECS - VALIDITY_PERIOD_PADDING_SECS, 0), + (period + 1) * UNPADDED_VALIDITY_PERIOD_SECS + VALIDITY_PERIOD_PADDING_SECS, + ]; +} + +/** DER certificate PolkaJAM presents for `compressed` during `period`. */ +export function webTransportCertificateDer(compressed: Uint8Array, period: number): Uint8Array { + const point = decompressP256(compressed); + const altName = peerIdText(compressed[0] === 3 ? "o" : "v", compressed.subarray(1)); + const [notBefore, notAfter] = validityBounds(period); + const spki = der(0x30, der(0x30, OID_EC_PUBLIC_KEY, OID_PRIME256V1), der(0x03, [0x00], point)); + const san = der(0x30, der(0x30, OID_SUBJECT_ALT_NAME, der(0x04, der(0x30, der(0x82, ascii.encode(altName)))))); + const tbs = der( + 0x30, + der(0xa0, der(0x02, [0x02])), + der(0x02, [0x00]), + ED25519_ALG, + JAM_DN, + der(0x30, utcTime(notBefore), utcTime(notAfter)), + JAM_DN, + spki, + der(0xa3, san), + ); + return Uint8Array.from(der(0x30, tbs, ED25519_ALG, der(0x03, [0x00], new Uint8Array(64)))); +} + +/** SHA-256 of {@link webTransportCertificateDer}. */ +export function webTransportCertificateHash(compressed: Uint8Array, period: number): Uint8Array { + return sha256(webTransportCertificateDer(compressed, period)); +} + +/** + * Hashes to pass as `serverCertificateHashes` at `unixSecs`: the current + * period plus both neighbours, so a clock skew or a boundary crossing during + * the handshake still matches whichever certificate the server picked. + */ +export function webTransportCertificateHashes(compressed: Uint8Array, unixSecs: number): Uint8Array[] { + const period = validityPeriodAt(unixSecs); + return [period - 1, period, period + 1] + .filter((p) => p >= 0) + .map((p) => webTransportCertificateHash(compressed, p)); +} diff --git a/js/packages/truapi/src/peer-transport-wire.test.ts b/js/packages/truapi/src/peer-transport-wire.test.ts new file mode 100644 index 0000000000..8fdf2b53f9 --- /dev/null +++ b/js/packages/truapi/src/peer-transport-wire.test.ts @@ -0,0 +1,91 @@ +import { expect, test } from "bun:test"; +import * as S from "./scale.js"; +import * as T from "./generated/types.js"; +import { + PEER_TRANSPORT_CLOSE, + PEER_TRANSPORT_DIAL, + PEER_TRANSPORT_EVENTS, + PEER_TRANSPORT_OPEN, + PEER_TRANSPORT_RECV, + PEER_TRANSPORT_RESET, + PEER_TRANSPORT_SEND, +} from "./generated/wire-table.js"; +import { decodeWireMessage, encodeWireMessage } from "./transport.js"; + +// The same bytes `rust/crates/truapi-server/tests/peer_transport_grant.rs` +// pins for the Rust SCALE codec: both sides must agree on the frozen V1 layout. +const GENESIS = "0x353963b9cedfe4ea22038081052a5c151b06b55a4a026a97522cd0320cabf49f" as const; +const LOOPBACK_V4_MAPPED = "0x00000000000000000000ffff7f000001" as const; + +test("PeerTransport is namespace 21 with methods 0..6 in contract order", () => { + const ids = [PEER_TRANSPORT_DIAL, PEER_TRANSPORT_OPEN, PEER_TRANSPORT_SEND, PEER_TRANSPORT_RECV, + PEER_TRANSPORT_RESET, PEER_TRANSPORT_CLOSE, PEER_TRANSPORT_EVENTS]; + ids.forEach((id, method) => { + expect(id.trait).toBe(21); + expect(id.method).toBe(method); + expect(id.kind).toBe("request"); + }); +}); + +test("dial request encodes genesis, v4-mapped ip, port, ed25519 and optional p256 as Rust does", () => { + const encoded = T.VersionedHostPeerTransportDialRequest.enc({ + tag: "V1", + value: { genesis: GENESIS, ip: LOOPBACK_V4_MAPPED, port: 43000, ed25519: `0x${"11".repeat(32)}`, p256: `0x${"02".repeat(33)}` }, + }); + expect([...encoded]).toEqual([ + 0, + ...S.hexToBytes(GENESIS), + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1, + 0xf8, 0xa7, + ...new Array(32).fill(0x11), + 1, + ...new Array(33).fill(0x02), + ]); + const withoutP256 = T.VersionedHostPeerTransportDialRequest.enc({ + tag: "V1", + value: { genesis: GENESIS, ip: LOOPBACK_V4_MAPPED, port: 43000, ed25519: `0x${"11".repeat(32)}`, p256: undefined }, + }); + expect(withoutP256.length).toBe(1 + 32 + 16 + 2 + 32 + 1); + expect(withoutP256[withoutP256.length - 1]).toBe(0); + expect(T.VersionedHostPeerTransportDialRequest.dec(withoutP256)).toEqual({ + tag: "V1", + value: { genesis: GENESIS, ip: LOOPBACK_V4_MAPPED, port: 43000, ed25519: `0x${"11".repeat(32)}`, p256: undefined }, + }); +}); + +test("send, recv and events payloads match the Rust SCALE bytes", () => { + expect([...T.VersionedHostPeerTransportSendRequest.enc({ tag: "V1", value: { stream: 7, message: "0xaabb", fin: true } })]) + .toEqual([0, 7, 0, 0, 0, 8, 0xaa, 0xbb, 1]); + expect([...T.VersionedHostPeerTransportRecvResponse.enc({ tag: "V1", value: { message: undefined, fin: false, reset: true } })]) + .toEqual([0, 0, 0, 1]); + expect([...T.VersionedHostPeerTransportRecvRequest.enc({ tag: "V1", value: { stream: 3, max: 1 << 20 } })]) + .toEqual([0, 3, 0, 0, 0, 0, 0, 0x10, 0]); + const events = T.VersionedHostPeerTransportEventsResponse.enc({ + tag: "V1", + value: { + events: [ + { tag: "ConnClosed", value: { conn: 1 } }, + { tag: "StreamFin", value: { stream: 2 } }, + { tag: "Accepted", value: { conn: 1, stream: 3, kind: 0 } }, + ], + }, + }); + expect([...events]).toEqual([0, 12, 0, 1, 0, 0, 0, 1, 2, 0, 0, 0, 2, 1, 0, 0, 0, 3, 0, 0, 0, 0]); + expect([...T.VersionedHostPeerTransportEventsRequest.enc({ tag: "V1", value: undefined })]).toEqual([0]); + expect([...T.VersionedHostPeerTransportDialError.enc({ tag: "V1", value: "Unreachable" })]).toEqual([0, 3]); +}); + +test("a NotGranted dial response decodes from a host frame", () => { + const resultCodec = S.Result(T.VersionedHostPeerTransportDialResponse, S.CallError(T.VersionedHostPeerTransportDialError)); + const value = resultCodec.enc({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "NotGranted" } } }); + const frame = encodeWireMessage({ requestId: "p", payload: { traitId: 21, methodId: 0, messageType: 1, value } }); + if (frame.isErr()) throw frame.error; + expect([...frame.value]).toEqual([4, 112, 21, 0, 1, 1, 0, 0, 0]); + const decoded = decodeWireMessage(frame.value); + if (decoded.isErr()) throw decoded.error; + expect(decoded.value.requestId).toBe("p"); + expect(resultCodec.dec(decoded.value.payload.value)).toEqual({ + success: false, + value: { tag: "Domain", value: { tag: "V1", value: "NotGranted" } }, + }); +}); diff --git a/js/packages/truapi/src/peer-transport.test.ts b/js/packages/truapi/src/peer-transport.test.ts new file mode 100644 index 0000000000..66b008304f --- /dev/null +++ b/js/packages/truapi/src/peer-transport.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, test } from "bun:test"; +import * as S from "./scale.js"; +import * as T from "./generated/types.js"; +import { TRUAPI_CODEC_VERSION } from "./generated/client.js"; +import { + PEER_TRANSPORT_CLOSE, + PEER_TRANSPORT_DIAL, + PEER_TRANSPORT_EVENTS, + PEER_TRANSPORT_OPEN, + PEER_TRANSPORT_RECV, + PEER_TRANSPORT_SEND, + SYSTEM_HANDSHAKE, +} from "./generated/wire-table.js"; +import { decodeWireMessage, encodeWireMessage, MESSAGE_TYPE_REQUEST, type MethodIds } from "./transport.js"; +import { + createPeerTransportSession, + frameTraitId, + peerUrl, + PEER_TRANSPORT_MAX_MESSAGE_BYTES, + type PeerTransportSession, + type WebTransportBidirectionalStreamLike, + type WebTransportLike, +} from "./peer-transport.js"; + +const GENESIS = "0x353963b9cedfe4ea22038081052a5c151b06b55a4a026a97522cd0320cabf49f"; +const P256 = "0x028874174c8f469438a1b1bab2fde75f9c4999461382ec6d47e9b3b4511294c607"; +const LOOPBACK = "0x00000000000000000000ffff7f000001"; + +interface FakeStream { + local: WebTransportBidirectionalStreamLike; + /** Bytes the session wrote, in order. */ + sent: Uint8Array[]; + peerWrite(bytes: Uint8Array): void; + peerFin(): void; +} + +interface FakeTransport extends WebTransportLike { + url: string; + hashes: Uint8Array[]; + streams: FakeStream[]; + /** Simulate the peer opening a stream toward us. */ + peerOpen(): FakeStream; + peerClose(): void; +} + +function fakeStream(): FakeStream { + const sent: Uint8Array[] = []; + let peerController!: ReadableStreamDefaultController; + const readable = new ReadableStream({ start: (c) => (peerController = c) }); + const writable = new WritableStream({ write: (chunk) => void sent.push(chunk) }); + return { + local: { readable, writable }, + sent, + peerWrite: (bytes) => peerController.enqueue(bytes), + peerFin: () => peerController.close(), + }; +} + +function fakeTransport(url: string, hashes: Uint8Array[], failReady = false): FakeTransport { + let incoming!: ReadableStreamDefaultController; + const closed = Promise.withResolvers(); + const streams: FakeStream[] = []; + return { + url, + hashes, + streams, + ready: failReady ? Promise.reject(new Error("refused")) : Promise.resolve(), + closed: closed.promise, + incomingBidirectionalStreams: new ReadableStream({ start: (c) => (incoming = c) }), + async createBidirectionalStream() { + const stream = fakeStream(); + streams.push(stream); + return stream.local; + }, + close: () => closed.resolve(), + peerOpen() { + const stream = fakeStream(); + streams.push(stream); + incoming.enqueue(stream.local); + return stream; + }, + peerClose: () => closed.resolve(), + }; +} + +let requestCounter = 0; +function frame(ids: MethodIds, value: Uint8Array): Uint8Array { + const encoded = encodeWireMessage({ + requestId: `t${requestCounter++}`, + payload: { traitId: ids.trait, methodId: ids.method, messageType: MESSAGE_TYPE_REQUEST, value }, + }); + if (encoded.isErr()) throw encoded.error; + return encoded.value; +} + +async function call(session: PeerTransportSession, ids: MethodIds, request: Uint8Array, codec: S.Codec): Promise { + const response = decodeWireMessage(await session.handleFrame(frame(ids, request))); + if (response.isErr()) throw response.error; + return codec.dec(response.value.payload.value); +} + +const dialCodec = S.Result(T.VersionedHostPeerTransportDialResponse, S.CallError(T.VersionedHostPeerTransportDialError)); +const openCodec = S.Result(T.VersionedHostPeerTransportOpenResponse, S.CallError(T.VersionedHostPeerTransportOpenError)); +const sendCodec = S.Result(T.VersionedHostPeerTransportSendResponse, S.CallError(T.VersionedHostPeerTransportSendError)); +const recvCodec = S.Result(T.VersionedHostPeerTransportRecvResponse, S.CallError(T.VersionedHostPeerTransportRecvError)); +const closeCodec = S.Result(T.VersionedHostPeerTransportCloseResponse, S.CallError(T.VersionedHostPeerTransportCloseError)); +const eventsCodec = S.Result(T.VersionedHostPeerTransportEventsResponse, S.CallError(T.VersionedHostPeerTransportEventsError)); +const handshakeCodec = S.Result(T.VersionedHostHandshakeResponse, S.CallError(T.VersionedHostHandshakeError)); + +function dialRequest(overrides: Partial = {}): Uint8Array { + return T.VersionedHostPeerTransportDialRequest.enc({ + tag: "V1", + value: { genesis: GENESIS, ip: LOOPBACK, port: 43000, ed25519: `0x${"11".repeat(32)}`, p256: P256, ...overrides }, + }); +} + +async function negotiated(options: { failReady?: boolean } = {}): Promise<{ session: PeerTransportSession; transports: FakeTransport[] }> { + const transports: FakeTransport[] = []; + const session = createPeerTransportSession({ + genesis: GENESIS, + now: () => 1_790_380_800, + connect: (url, hashes) => { + const transport = fakeTransport(url, hashes, options.failReady); + transports.push(transport); + return transport; + }, + }); + const handshake = await call(session, SYSTEM_HANDSHAKE, T.VersionedHostHandshakeRequest.enc({ tag: "V1", value: { codecVersion: TRUAPI_CODEC_VERSION } }), handshakeCodec); + expect(handshake.success).toBe(true); + return { session, transports }; +} + +async function dialed(): Promise<{ session: PeerTransportSession; transport: FakeTransport; conn: number }> { + const { session, transports } = await negotiated(); + const dial = await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); + if (!dial.success) throw new Error("dial failed"); + return { session, transport: transports[0]!, conn: dial.value.value.conn }; +} + +/** Yield one macrotask so stream pumps observe enqueued chunks; 0 ms, not a duration guess. */ +function tick(): Promise { + const { promise, resolve } = Promise.withResolvers(); + setTimeout(resolve, 0); + return promise; +} + +describe("peerUrl", () => { + test("renders v4-mapped and native IPv6 authorities", () => { + expect(peerUrl(S.hexToBytes(LOOPBACK), 43000)).toBe("https://127.0.0.1:43000"); + expect(peerUrl(S.hexToBytes(`0x${"00".repeat(15)}01`), 443)).toBe("https://[0:0:0:0:0:0:0:1]:443"); + }); +}); + +describe("grant", () => { + test("dial before the handshake is NotGranted", async () => { + const session = createPeerTransportSession({ genesis: GENESIS, connect: () => fakeTransport("", []) }); + const dial = await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); + expect(dial).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "NotGranted" } } }); + }); + + test("dial for another genesis is NotGranted; without p256 it is Unreachable", async () => { + const { session, transports } = await negotiated(); + const other = await call(session, PEER_TRANSPORT_DIAL, dialRequest({ genesis: `0x${"ab".repeat(32)}` }), dialCodec); + expect(other).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "NotGranted" } } }); + const quicOnly = await call(session, PEER_TRANSPORT_DIAL, dialRequest({ p256: undefined }), dialCodec); + expect(quicOnly).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Unreachable" } } }); + expect(transports).toHaveLength(0); + }); + + test("frames for other traits are Denied and the trait id is exposed for routing", async () => { + const { session } = await negotiated(); + const bytes = frame({ trait: 20, method: 0, kind: "request" }, new Uint8Array()); + expect(frameTraitId(bytes)).toBe(20); + const response = decodeWireMessage(await session.handleFrame(bytes)); + expect(response.isOk() && S.Result(S._void, S.CallError(S._void)).dec(response.value.payload.value)).toEqual({ success: false, value: { tag: "Denied" } }); + }); +}); + +describe("dial", () => { + test("connects to the peer URL with the three period certificate hashes", async () => { + const { transport, conn } = await dialed(); + expect(conn).toBe(1); + expect(transport.url).toBe("https://127.0.0.1:43000"); + expect(transport.hashes.map((h) => S.bytesToHex(h))).toEqual([ + "0xccf30196b29007b42fca6f406363ce17781bab0f011bac47dcbe307e0e6a316d", + "0xeb09b6b027f5953cb8ca2e8f296e21052c3423370876e67f1634180ddf99f1ec", + "0x8bdfa3a2b7822822f5da33fadfa118d39d05b0a6b1086fc82a62a2209f6fae27", + ]); + }); + + test("a rejected handshake is Refused and holds no connection slot", async () => { + const { session } = await negotiated({ failReady: true }); + const dial = await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); + expect(dial).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Refused" } } }); + const close = await call(session, PEER_TRANSPORT_CLOSE, T.VersionedHostPeerTransportCloseRequest.enc({ tag: "V1", value: { conn: 1 } }), closeCodec); + expect(close.success).toBe(false); + }); + + test("the ninth connection hits Limit", async () => { + const { session } = await negotiated(); + for (let i = 0; i < 8; i++) { + expect((await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + } + const ninth = await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); + expect(ninth).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Limit" } } }); + }); +}); + +describe("streams", () => { + test("open sends the kind byte; send frames with a u32-LE prefix; recv unframes", async () => { + const { session, transport, conn } = await dialed(); + const open = await call(session, PEER_TRANSPORT_OPEN, T.VersionedHostPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 128 } }), openCodec); + if (!open.success) throw new Error("open failed"); + const stream = open.value.value.stream; + const send = await call(session, PEER_TRANSPORT_SEND, T.VersionedHostPeerTransportSendRequest.enc({ tag: "V1", value: { stream, message: "0x0102", fin: true } }), sendCodec); + expect(send.success).toBe(true); + const wire = transport.streams[0]!; + expect(wire.sent.map((c) => S.bytesToHex(c))).toEqual(["0x80", "0x020000000102"]); + + // Peer replies with two messages split across arbitrary chunk boundaries, then FIN. + wire.peerWrite(new Uint8Array([3, 0, 0, 0, 0xaa])); + wire.peerWrite(new Uint8Array([0xbb, 0xcc, 1, 0, 0])); + await tick(); + const early = await call(session, PEER_TRANSPORT_RECV, T.VersionedHostPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 << 20 } }), recvCodec); + expect(early).toEqual({ success: true, value: { tag: "V1", value: { message: "0xaabbcc", fin: false, reset: false } } }); + wire.peerWrite(new Uint8Array([0, 0xdd])); + wire.peerFin(); + await tick(); + await tick(); + const second = await call(session, PEER_TRANSPORT_RECV, T.VersionedHostPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 << 20 } }), recvCodec); + expect(second).toEqual({ success: true, value: { tag: "V1", value: { message: "0xdd", fin: true, reset: false } } }); + const drained = await call(session, PEER_TRANSPORT_RECV, T.VersionedHostPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 << 20 } }), recvCodec); + expect(drained).toEqual({ success: true, value: { tag: "V1", value: { message: undefined, fin: true, reset: false } } }); + const consumed = await call(session, PEER_TRANSPORT_RECV, T.VersionedHostPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 << 20 } }), recvCodec); + expect(consumed).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Closed" } } }); + const events = await call(session, PEER_TRANSPORT_EVENTS, T.VersionedHostPeerTransportEventsRequest.enc({ tag: "V1" }), eventsCodec); + expect(events).toEqual({ success: true, value: { tag: "V1", value: { events: [{ tag: "StreamFin", value: { stream } }] } } }); + }); + + test("oversized send is TooLarge and a message above the caller's max resets the stream", async () => { + const { session, transport, conn } = await dialed(); + const open = await call(session, PEER_TRANSPORT_OPEN, T.VersionedHostPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + if (!open.success) throw new Error("open failed"); + const stream = open.value.value.stream; + const big = `0x${"00".repeat(PEER_TRANSPORT_MAX_MESSAGE_BYTES + 1)}` as const; + const send = await call(session, PEER_TRANSPORT_SEND, T.VersionedHostPeerTransportSendRequest.enc({ tag: "V1", value: { stream, message: big, fin: false } }), sendCodec); + expect(send).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "TooLarge" } } }); + transport.streams[0]!.peerWrite(new Uint8Array([2, 0, 0, 0, 1, 2])); + await tick(); + const recv = await call(session, PEER_TRANSPORT_RECV, T.VersionedHostPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 } }), recvCodec); + expect(recv).toEqual({ success: true, value: { tag: "V1", value: { message: undefined, fin: false, reset: true } } }); + }); + + test("peer-opened streams surface as Accepted with their kind byte", async () => { + const { session, transport, conn } = await dialed(); + const incoming = transport.peerOpen(); + incoming.peerWrite(new Uint8Array([0, 2, 0, 0, 0, 9, 9])); + await tick(); + await tick(); + const events = await call(session, PEER_TRANSPORT_EVENTS, T.VersionedHostPeerTransportEventsRequest.enc({ tag: "V1" }), eventsCodec); + expect(events).toEqual({ success: true, value: { tag: "V1", value: { events: [{ tag: "Accepted", value: { conn, stream: 1, kind: 0 } }] } } }); + const recv = await call(session, PEER_TRANSPORT_RECV, T.VersionedHostPeerTransportRecvRequest.enc({ tag: "V1", value: { stream: 1, max: 1 << 20 } }), recvCodec); + expect(recv).toEqual({ success: true, value: { tag: "V1", value: { message: "0x0909", fin: false, reset: false } } }); + }); + + test("a peer close reports ConnClosed and invalidates streams; session close denies everything", async () => { + const { session, transport, conn } = await dialed(); + const open = await call(session, PEER_TRANSPORT_OPEN, T.VersionedHostPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + if (!open.success) throw new Error("open failed"); + transport.peerClose(); + await tick(); + await tick(); + const events = await call(session, PEER_TRANSPORT_EVENTS, T.VersionedHostPeerTransportEventsRequest.enc({ tag: "V1" }), eventsCodec); + expect(events).toEqual({ success: true, value: { tag: "V1", value: { events: [{ tag: "ConnClosed", value: { conn } }] } } }); + const send = await call(session, PEER_TRANSPORT_SEND, T.VersionedHostPeerTransportSendRequest.enc({ tag: "V1", value: { stream: open.value.value.stream, message: "0x00", fin: false } }), sendCodec); + expect(send).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Closed" } } }); + session.close(); + const response = decodeWireMessage(await session.handleFrame(frame(PEER_TRANSPORT_DIAL, dialRequest()))); + expect(response.isOk() && S.Result(S._void, S.CallError(S._void)).dec(response.value.payload.value)).toEqual({ success: false, value: { tag: "Denied" } }); + }); +}); diff --git a/js/packages/truapi/src/peer-transport.ts b/js/packages/truapi/src/peer-transport.ts new file mode 100644 index 0000000000..a7d882d612 --- /dev/null +++ b/js/packages/truapi/src/peer-transport.ts @@ -0,0 +1,515 @@ +import * as S from "./scale.js"; +import * as T from "./generated/types.js"; +import { TRUAPI_CODEC_VERSION } from "./generated/client.js"; +import { + PEER_TRANSPORT_CLOSE, + PEER_TRANSPORT_DIAL, + PEER_TRANSPORT_EVENTS, + PEER_TRANSPORT_OPEN, + PEER_TRANSPORT_RECV, + PEER_TRANSPORT_RESET, + PEER_TRANSPORT_SEND, + SYSTEM_HANDSHAKE, +} from "./generated/wire-table.js"; +import { + decodeWireMessage, + encodeWireMessage, + MESSAGE_TYPE_CANCEL, + MESSAGE_TYPE_REQUEST, + MESSAGE_TYPE_RESPONSE, + type MethodIds, + type ProtocolMessage, +} from "./transport.js"; +import { webTransportCertificateHashes } from "./peer-transport-cert.js"; + +/** Caps mirrored from `truapi::v01::peer_transport`. */ +export const PEER_TRANSPORT_MAX_CONNECTIONS = 8; +export const PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION = 16; +export const PEER_TRANSPORT_MAX_MESSAGE_BYTES = 1 << 20; +export const PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION = 4 << 20; +/** Largest request frame: a `send` of a maximal message plus SCALE and wire overhead. */ +export const PEER_TRANSPORT_MAX_FRAME_BYTES = PEER_TRANSPORT_MAX_MESSAGE_BYTES + 4096; +const MAX_PENDING_EVENTS = 1024; +const DIAL_TIMEOUT_MS = 10_000; +const textEncoder = new TextEncoder(); + +const handshakeResult = S.Result(T.VersionedHostHandshakeResponse, S.CallError(T.VersionedHostHandshakeError)); +const frameworkResult = S.Result(S._void, S.CallError(S._void)); +const dialResult = S.Result(T.VersionedHostPeerTransportDialResponse, S.CallError(T.VersionedHostPeerTransportDialError)); +const openResult = S.Result(T.VersionedHostPeerTransportOpenResponse, S.CallError(T.VersionedHostPeerTransportOpenError)); +const sendResult = S.Result(T.VersionedHostPeerTransportSendResponse, S.CallError(T.VersionedHostPeerTransportSendError)); +const recvResult = S.Result(T.VersionedHostPeerTransportRecvResponse, S.CallError(T.VersionedHostPeerTransportRecvError)); +const resetResult = S.Result(T.VersionedHostPeerTransportResetResponse, S.CallError(T.VersionedHostPeerTransportResetError)); +const closeResult = S.Result(T.VersionedHostPeerTransportCloseResponse, S.CallError(T.VersionedHostPeerTransportCloseError)); +const eventsResult = S.Result(T.VersionedHostPeerTransportEventsResponse, S.CallError(T.VersionedHostPeerTransportEventsError)); + +/** Minimal WebTransport surface the session needs; lets tests inject a fake. */ +export interface WebTransportLike { + readonly ready: Promise; + readonly closed: Promise; + readonly incomingBidirectionalStreams: ReadableStream; + createBidirectionalStream(): Promise; + close(): void; +} + +export interface WebTransportBidirectionalStreamLike { + readonly readable: ReadableStream; + readonly writable: WritableStream; +} + +/** A host-owned grant for one JAM genesis; the guest can neither create nor widen it. */ +export interface PeerTransportGrant { + /** `0x`-prefixed lower-case 32-byte genesis header hash. */ + genesis: string; +} + +export interface PeerTransportOptions extends PeerTransportGrant { + /** Host transport injection; defaults to the browser `WebTransport` constructor. */ + connect?: (url: string, certificateHashes: Uint8Array[]) => WebTransportLike; + /** Unix seconds used to select certificate validity periods; defaults to the wall clock. */ + now?: () => number; +} + +/** Execution-local peer endpoint. It provides no account or signing authority. */ +export interface PeerTransportSession { + /** Handle one request frame; CANCEL frames return zero bytes. */ + handleFrame(frame: Uint8Array): Promise; + /** Revoke the grant and close every connection on stop or replacement. */ + close(): void; +} + +/** Validate and normalize the manifest `capabilities.network.jam.genesis` value. */ +export function validatePeerTransportGenesis(genesis: string): string { + const hex = genesis.startsWith("0x") ? genesis.slice(2) : genesis; + if (!/^[0-9a-f]{64}$/.test(hex)) { + throw new Error("JAM genesis must be a 32-byte lower-case hex header hash"); + } + return `0x${hex}`; +} + +/** Trait id of a request frame, or `undefined` when it does not decode. */ +export function frameTraitId(frame: Uint8Array): number | undefined { + const decoded = decodeWireMessage(frame); + return decoded.isOk() ? decoded.value.payload.traitId : undefined; +} + +function exact(codec: S.Codec, bytes: Uint8Array): V { + const value = codec.dec(bytes); + const canonical = codec.enc(value); + if (canonical.length !== bytes.length || canonical.some((byte, index) => byte !== bytes[index])) { + throw new Error("Noncanonical or trailing SCALE bytes"); + } + return value; +} + +function decodeFrame(bytes: Uint8Array): ProtocolMessage { + if (!(bytes instanceof Uint8Array) || bytes.length > PEER_TRANSPORT_MAX_FRAME_BYTES) { + throw new Error("Invalid or oversized peer-transport frame"); + } + const decoded = decodeWireMessage(bytes); + if (decoded.isErr()) throw decoded.error; + const message = decoded.value; + if (textEncoder.encode(message.requestId).length > 64) throw new Error("Oversized request id"); + const encoded = encodeWireMessage(message); + if (encoded.isErr()) throw encoded.error; + if (encoded.value.length !== bytes.length || encoded.value.some((byte, index) => byte !== bytes[index])) { + throw new Error("Noncanonical request frame"); + } + return message; +} + +function hasIds(message: ProtocolMessage, ids: MethodIds): boolean { + return message.payload.traitId === ids.trait && message.payload.methodId === ids.method; +} + +function reply(request: ProtocolMessage, value: Uint8Array): Uint8Array { + const encoded = encodeWireMessage({ + requestId: request.requestId, + payload: { ...request.payload, messageType: MESSAGE_TYPE_RESPONSE, value }, + }); + if (encoded.isErr()) throw encoded.error; + return encoded.value; +} + +function ok(codec: S.Codec>, value: NoInfer): Uint8Array { + return codec.enc({ success: true, value }); +} + +function domain(codec: S.Codec>>, error: E): Uint8Array { + return codec.enc({ success: false, value: { tag: "Domain", value: { tag: "V1", value: error } } }); +} + +/** `https://` authority for a 16-byte IPv6 or v4-mapped address. */ +export function peerUrl(ip: Uint8Array, port: number): string { + if (ip.length !== 16) throw new Error("peer ip must be 16 bytes"); + const v4Mapped = ip.subarray(0, 10).every((byte) => byte === 0) && ip[10] === 0xff && ip[11] === 0xff; + if (v4Mapped) return `https://${ip[12]}.${ip[13]}.${ip[14]}.${ip[15]}:${port}`; + const groups: string[] = []; + for (let i = 0; i < 16; i += 2) groups.push(((ip[i]! << 8) | ip[i + 1]!).toString(16)); + return `https://[${groups.join(":")}]:${port}`; +} + +interface PeerStream { + id: number; + conn: PeerConnection; + writer: WritableStreamDefaultWriter; + reader: ReadableStreamDefaultReader; + /** Unparsed receive bytes. */ + rx: Uint8Array; + /** Complete messages not yet delivered by `recv`. */ + messages: Uint8Array[]; + fin: boolean; + reset: boolean; + /** `recv` reported `fin` with an empty queue; further reads are `Closed`. */ + rxConsumed: boolean; + txClosed: boolean; + /** Bytes of frames handed to the writer that have not been accepted yet. */ + txPending: number; +} + +interface PeerConnection { + id: number; + transport: WebTransportLike; + streams: Map; + closed: boolean; +} + +/** + * Create the browser PeerTransport endpoint for one execution. The host must + * have checked the manifest grant before calling this constructor and must + * fence late replies against execution stop or replacement. + */ +export function createPeerTransportSession(options: PeerTransportOptions): PeerTransportSession { + const genesis = validatePeerTransportGenesis(options.genesis); + const connect = + options.connect ?? + ((url, hashes): WebTransportLike => + new WebTransport(url, { + serverCertificateHashes: hashes.map((value) => ({ algorithm: "sha-256", value: value as Uint8Array })), + }) as unknown as WebTransportLike); + const now = options.now ?? ((): number => Math.floor(Date.now() / 1000)); + let closed = false; + let negotiated = false; + let nextConn = 1; + let nextStream = 1; + const connections = new Map(); + const streams = new Map(); + const events: T.PeerTransportEvent[] = []; + + const pushEvent = (event: T.PeerTransportEvent): void => { + if (events.length < MAX_PENDING_EVENTS) events.push(event); + }; + + const dropStream = (stream: PeerStream, abort: boolean): void => { + streams.delete(stream.id); + stream.conn.streams.delete(stream.id); + if (abort) { + void stream.writer.abort().catch(() => undefined); + void stream.reader.cancel().catch(() => undefined); + } + }; + + const dropConnection = (conn: PeerConnection): void => { + if (conn.closed) return; + conn.closed = true; + connections.delete(conn.id); + for (const stream of [...conn.streams.values()]) dropStream(stream, true); + try { + conn.transport.close(); + } catch { + // Already closed by the peer. + } + pushEvent({ tag: "ConnClosed", value: { conn: conn.id } }); + }; + + /** Parse complete `u32`-LE framed messages out of `stream.rx`. */ + const unframe = (stream: PeerStream): void => { + while (stream.rx.length >= 4) { + const view = new DataView(stream.rx.buffer, stream.rx.byteOffset, stream.rx.byteLength); + const length = view.getUint32(0, true); + if (length > PEER_TRANSPORT_MAX_MESSAGE_BYTES) { + stream.reset = true; + void stream.writer.abort().catch(() => undefined); + void stream.reader.cancel().catch(() => undefined); + stream.rx = new Uint8Array(); + return; + } + if (stream.rx.length < 4 + length) return; + stream.messages.push(stream.rx.slice(4, 4 + length)); + stream.rx = stream.rx.slice(4 + length); + } + }; + + const pump = async (stream: PeerStream, initial: Uint8Array): Promise => { + stream.rx = initial; + unframe(stream); + try { + while (!stream.reset) { + const { value, done } = await stream.reader.read(); + if (done) break; + const next = new Uint8Array(stream.rx.length + value.length); + next.set(stream.rx); + next.set(value, stream.rx.length); + stream.rx = next; + unframe(stream); + } + if (!stream.reset) { + stream.fin = true; + if (stream.rx.length !== 0) stream.reset = true; + if (streams.has(stream.id)) pushEvent({ tag: "StreamFin", value: { stream: stream.id } }); + } + } catch { + stream.reset = true; + } + }; + + const register = (conn: PeerConnection, bidi: WebTransportBidirectionalStreamLike, initial: Uint8Array): PeerStream => { + const stream: PeerStream = { + id: nextStream++, + conn, + writer: bidi.writable.getWriter(), + reader: bidi.readable.getReader(), + rx: new Uint8Array(), + messages: [], + fin: false, + reset: false, + rxConsumed: false, + txClosed: false, + txPending: 0, + }; + streams.set(stream.id, stream); + conn.streams.set(stream.id, stream); + void pump(stream, initial); + return stream; + }; + + const acceptLoop = async (conn: PeerConnection): Promise => { + const incoming = conn.transport.incomingBidirectionalStreams.getReader(); + try { + while (!conn.closed) { + const { value: bidi, done } = await incoming.read(); + if (done || conn.closed) break; + if (conn.streams.size >= PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION) { + void bidi.writable.abort().catch(() => undefined); + void bidi.readable.cancel().catch(() => undefined); + continue; + } + // The peer's first byte is the stream kind; anything after it is message data. + const reader = bidi.readable.getReader(); + const first = await reader.read(); + reader.releaseLock(); + if (first.done || first.value.length === 0 || conn.closed) { + void bidi.writable.abort().catch(() => undefined); + continue; + } + const stream = register(conn, bidi, first.value.subarray(1)); + pushEvent({ tag: "Accepted", value: { conn: conn.id, stream: stream.id, kind: first.value[0]! } }); + } + } catch { + // The connection is closing; `closed` handling reports it. + } + }; + + const dial = async (request: T.HostPeerTransportDialRequest): Promise => { + if (request.genesis !== genesis) return domain(dialResult, "NotGranted"); + if (connections.size >= PEER_TRANSPORT_MAX_CONNECTIONS) return domain(dialResult, "Limit"); + // Browsers only expose WebTransport; JAMNP-S QUIC needs the P-256 identity. + if (request.p256 === undefined) return domain(dialResult, "Unreachable"); + const p256 = S.hexToBytes(request.p256); + if (p256.length !== 33 || (p256[0] !== 2 && p256[0] !== 3)) return domain(dialResult, "Refused"); + let transport: WebTransportLike; + try { + transport = connect(peerUrl(S.hexToBytes(request.ip), request.port), webTransportCertificateHashes(p256, now())); + } catch { + return domain(dialResult, "Unreachable"); + } + const conn: PeerConnection = { id: nextConn++, transport, streams: new Map(), closed: false }; + connections.set(conn.id, conn); + // Executor form: this package's lib target predates Promise.withResolvers. + let timer: number | undefined; + try { + await Promise.race([ + transport.ready, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error("timeout")), DIAL_TIMEOUT_MS) as unknown as number; + }), + ]); + } catch (error) { + connections.delete(conn.id); + conn.closed = true; + try { + transport.close(); + } catch { + // Never opened. + } + return domain(dialResult, error instanceof Error && error.message === "timeout" ? "Unreachable" : "Refused"); + } finally { + clearTimeout(timer); + } + if (closed) { + dropConnection(conn); + return frameworkResult.enc({ success: false, value: { tag: "Denied" } }); + } + void transport.closed.then( + () => dropConnection(conn), + () => dropConnection(conn), + ); + void acceptLoop(conn); + return ok(dialResult, { tag: "V1", value: { conn: conn.id } }); + }; + + const open = async (request: T.HostPeerTransportOpenRequest): Promise => { + const conn = connections.get(request.conn); + if (conn === undefined || conn.closed) return domain(openResult, "Closed"); + if (conn.streams.size >= PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION) return domain(openResult, "Limit"); + try { + const bidi = await conn.transport.createBidirectionalStream(); + const stream = register(conn, bidi, new Uint8Array()); + await stream.writer.write(new Uint8Array([request.kind])); + return ok(openResult, { tag: "V1", value: { stream: stream.id } }); + } catch { + return domain(openResult, "Closed"); + } + }; + + const send = async (request: T.HostPeerTransportSendRequest): Promise => { + const stream = streams.get(request.stream); + if (stream === undefined || stream.txClosed || stream.reset || stream.conn.closed) return domain(sendResult, "Closed"); + const message = S.hexToBytes(request.message); + if (message.length > PEER_TRANSPORT_MAX_MESSAGE_BYTES) return domain(sendResult, "TooLarge"); + let pending = 0; + for (const other of stream.conn.streams.values()) pending += other.txPending; + if (pending + message.length + 4 > PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION) return domain(sendResult, "Limit"); + const frame = new Uint8Array(4 + message.length); + new DataView(frame.buffer).setUint32(0, message.length, true); + frame.set(message, 4); + stream.txPending += frame.length; + try { + await stream.writer.write(frame); + if (request.fin) { + stream.txClosed = true; + await stream.writer.close(); + } + return ok(sendResult, { tag: "V1" }); + } catch { + stream.txClosed = true; + return domain(sendResult, "Closed"); + } finally { + stream.txPending -= frame.length; + } + }; + + const recv = (request: T.HostPeerTransportRecvRequest): Uint8Array => { + const stream = streams.get(request.stream); + if (stream === undefined || stream.rxConsumed) return domain(recvResult, "Closed"); + const next = stream.messages[0]; + if (next !== undefined && next.length > request.max) { + // The guest cannot take this message; treat it as a protocol violation. + stream.messages.length = 0; + stream.reset = true; + void stream.writer.abort().catch(() => undefined); + void stream.reader.cancel().catch(() => undefined); + } + let message: S.HexString | undefined; + if (!stream.reset && next !== undefined) { + stream.messages.shift(); + message = S.bytesToHex(next); + } + const drained = stream.messages.length === 0; + const fin = stream.fin && drained; + const reset = stream.reset; + if (message === undefined && (fin || reset)) { + stream.rxConsumed = true; + if (stream.txClosed || reset) dropStream(stream, reset); + } + return ok(recvResult, { tag: "V1", value: { message, fin, reset } }); + }; + + const reset = (request: T.HostPeerTransportResetRequest): Uint8Array => { + const stream = streams.get(request.stream); + if (stream === undefined) return domain(resetResult, "Closed"); + dropStream(stream, true); + return ok(resetResult, { tag: "V1" }); + }; + + const close = (request: T.HostPeerTransportCloseRequest): Uint8Array => { + const conn = connections.get(request.conn); + if (conn === undefined || conn.closed) return domain(closeResult, "Closed"); + dropConnection(conn); + return ok(closeResult, { tag: "V1" }); + }; + + return { + async handleFrame(bytes) { + const request = decodeFrame(bytes); + if (request.payload.messageType === MESSAGE_TYPE_CANCEL) { + if (request.payload.traitId !== PEER_TRANSPORT_DIAL.trait || request.payload.value.length !== 0) { + throw new Error("Invalid cancellation frame"); + } + // Every method answers within one host tick; nothing is cancellable. + return new Uint8Array(); + } + if (request.payload.messageType !== MESSAGE_TYPE_REQUEST) { + throw new Error("Invalid request frame"); + } + if (closed) return reply(request, frameworkResult.enc({ success: false, value: { tag: "Denied" } })); + if (hasIds(request, SYSTEM_HANDSHAKE)) { + let handshake: T.VersionedHostHandshakeRequest; + try { + handshake = exact(T.VersionedHostHandshakeRequest, request.payload.value); + } catch { + return reply(request, frameworkResult.enc({ success: false, value: { tag: "MalformedFrame", value: { reason: "invalid handshake" } } })); + } + if (handshake.value.codecVersion !== TRUAPI_CODEC_VERSION) { + return reply(request, handshakeResult.enc({ success: false, value: { tag: "Domain", value: { tag: "V1", value: { tag: "UnsupportedProtocolVersion" } } } })); + } + negotiated = true; + return reply(request, handshakeResult.enc({ success: true, value: { tag: "V1" } })); + } + if (request.payload.traitId !== PEER_TRANSPORT_DIAL.trait) { + return reply(request, frameworkResult.enc({ success: false, value: { tag: "Denied" } })); + } + const malformed = (): Uint8Array => + reply(request, frameworkResult.enc({ success: false, value: { tag: "MalformedFrame", value: { reason: "invalid peer-transport request" } } })); + try { + if (hasIds(request, PEER_TRANSPORT_DIAL)) { + const value = exact(T.VersionedHostPeerTransportDialRequest, request.payload.value).value; + return reply(request, negotiated ? await dial(value) : domain(dialResult, "NotGranted")); + } + if (hasIds(request, PEER_TRANSPORT_OPEN)) { + const value = exact(T.VersionedHostPeerTransportOpenRequest, request.payload.value).value; + return reply(request, negotiated ? await open(value) : domain(openResult, "NotGranted")); + } + if (hasIds(request, PEER_TRANSPORT_SEND)) { + const value = exact(T.VersionedHostPeerTransportSendRequest, request.payload.value).value; + return reply(request, negotiated ? await send(value) : domain(sendResult, "Closed")); + } + if (hasIds(request, PEER_TRANSPORT_RECV)) { + const value = exact(T.VersionedHostPeerTransportRecvRequest, request.payload.value).value; + return reply(request, negotiated ? recv(value) : domain(recvResult, "Closed")); + } + if (hasIds(request, PEER_TRANSPORT_RESET)) { + const value = exact(T.VersionedHostPeerTransportResetRequest, request.payload.value).value; + return reply(request, negotiated ? reset(value) : domain(resetResult, "Closed")); + } + if (hasIds(request, PEER_TRANSPORT_CLOSE)) { + const value = exact(T.VersionedHostPeerTransportCloseRequest, request.payload.value).value; + return reply(request, negotiated ? close(value) : domain(closeResult, "Closed")); + } + if (hasIds(request, PEER_TRANSPORT_EVENTS)) { + exact(T.VersionedHostPeerTransportEventsRequest, request.payload.value); + if (!negotiated) return reply(request, domain(eventsResult, "NotGranted")); + return reply(request, ok(eventsResult, { tag: "V1", value: { events: events.splice(0, events.length) } })); + } + } catch { + return malformed(); + } + return reply(request, frameworkResult.enc({ success: false, value: { tag: "Unsupported" } })); + }, + close() { + closed = true; + for (const conn of [...connections.values()]) dropConnection(conn); + events.length = 0; + }, + }; +} diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index be782ade46..aa62b391d0 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "462dacb6e0d1f504"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "4260ce2fcc2d5cfe"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; @@ -1384,6 +1384,195 @@ impl RequestMethod for PaymentTopUp { const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; } +/// `peer_transport_dial` method marker. +pub struct PeerTransportDial; +impl PeerTransportDial { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "PeerTransport", + method: "dial", + wire_name: "peer_transport_dial", + request_type: "truapi::versioned::peer_transport::HostPeerTransportDialRequest", + response_type: "truapi::versioned::peer_transport::HostPeerTransportDialResponse", + error_type: Some("truapi::versioned::peer_transport::HostPeerTransportDialError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 21, + method_id: 0, + }), + }; +} +impl RequestMethod for PeerTransportDial { + type Request = truapi::versioned::peer_transport::HostPeerTransportDialRequest; + type Response = truapi::versioned::peer_transport::HostPeerTransportDialResponse; + type Error = truapi::versioned::peer_transport::HostPeerTransportDialError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `peer_transport_open` method marker. +pub struct PeerTransportOpen; +impl PeerTransportOpen { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "PeerTransport", + method: "open", + wire_name: "peer_transport_open", + request_type: "truapi::versioned::peer_transport::HostPeerTransportOpenRequest", + response_type: "truapi::versioned::peer_transport::HostPeerTransportOpenResponse", + error_type: Some("truapi::versioned::peer_transport::HostPeerTransportOpenError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 21, + method_id: 1, + }), + }; +} +impl RequestMethod for PeerTransportOpen { + type Request = truapi::versioned::peer_transport::HostPeerTransportOpenRequest; + type Response = truapi::versioned::peer_transport::HostPeerTransportOpenResponse; + type Error = truapi::versioned::peer_transport::HostPeerTransportOpenError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `peer_transport_send` method marker. +pub struct PeerTransportSend; +impl PeerTransportSend { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "PeerTransport", + method: "send", + wire_name: "peer_transport_send", + request_type: "truapi::versioned::peer_transport::HostPeerTransportSendRequest", + response_type: "truapi::versioned::peer_transport::HostPeerTransportSendResponse", + error_type: Some("truapi::versioned::peer_transport::HostPeerTransportSendError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 21, + method_id: 2, + }), + }; +} +impl RequestMethod for PeerTransportSend { + type Request = truapi::versioned::peer_transport::HostPeerTransportSendRequest; + type Response = truapi::versioned::peer_transport::HostPeerTransportSendResponse; + type Error = truapi::versioned::peer_transport::HostPeerTransportSendError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `peer_transport_recv` method marker. +pub struct PeerTransportRecv; +impl PeerTransportRecv { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "PeerTransport", + method: "recv", + wire_name: "peer_transport_recv", + request_type: "truapi::versioned::peer_transport::HostPeerTransportRecvRequest", + response_type: "truapi::versioned::peer_transport::HostPeerTransportRecvResponse", + error_type: Some("truapi::versioned::peer_transport::HostPeerTransportRecvError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 21, + method_id: 3, + }), + }; +} +impl RequestMethod for PeerTransportRecv { + type Request = truapi::versioned::peer_transport::HostPeerTransportRecvRequest; + type Response = truapi::versioned::peer_transport::HostPeerTransportRecvResponse; + type Error = truapi::versioned::peer_transport::HostPeerTransportRecvError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `peer_transport_reset` method marker. +pub struct PeerTransportReset; +impl PeerTransportReset { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "PeerTransport", + method: "reset", + wire_name: "peer_transport_reset", + request_type: "truapi::versioned::peer_transport::HostPeerTransportResetRequest", + response_type: "truapi::versioned::peer_transport::HostPeerTransportResetResponse", + error_type: Some("truapi::versioned::peer_transport::HostPeerTransportResetError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 21, + method_id: 4, + }), + }; +} +impl RequestMethod for PeerTransportReset { + type Request = truapi::versioned::peer_transport::HostPeerTransportResetRequest; + type Response = truapi::versioned::peer_transport::HostPeerTransportResetResponse; + type Error = truapi::versioned::peer_transport::HostPeerTransportResetError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `peer_transport_close` method marker. +pub struct PeerTransportClose; +impl PeerTransportClose { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "PeerTransport", + method: "close", + wire_name: "peer_transport_close", + request_type: "truapi::versioned::peer_transport::HostPeerTransportCloseRequest", + response_type: "truapi::versioned::peer_transport::HostPeerTransportCloseResponse", + error_type: Some("truapi::versioned::peer_transport::HostPeerTransportCloseError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 21, + method_id: 5, + }), + }; +} +impl RequestMethod for PeerTransportClose { + type Request = truapi::versioned::peer_transport::HostPeerTransportCloseRequest; + type Response = truapi::versioned::peer_transport::HostPeerTransportCloseResponse; + type Error = truapi::versioned::peer_transport::HostPeerTransportCloseError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `peer_transport_events` method marker. +pub struct PeerTransportEvents; +impl PeerTransportEvents { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "PeerTransport", + method: "events", + wire_name: "peer_transport_events", + request_type: "truapi::versioned::peer_transport::HostPeerTransportEventsRequest", + response_type: "truapi::versioned::peer_transport::HostPeerTransportEventsResponse", + error_type: Some("truapi::versioned::peer_transport::HostPeerTransportEventsError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 21, + method_id: 6, + }), + }; +} +impl RequestMethod for PeerTransportEvents { + type Request = truapi::versioned::peer_transport::HostPeerTransportEventsRequest; + type Response = truapi::versioned::peer_transport::HostPeerTransportEventsResponse; + type Error = truapi::versioned::peer_transport::HostPeerTransportEventsError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + /// `permissions_request_device_permission` method marker. pub struct PermissionsRequestDevicePermission; impl PermissionsRequestDevicePermission { @@ -2277,6 +2466,13 @@ pub const APP_METHODS: &[MethodDescriptor] = &[ PaymentRequest::DESCRIPTOR, PaymentStatusSubscribe::DESCRIPTOR, PaymentTopUp::DESCRIPTOR, + PeerTransportDial::DESCRIPTOR, + PeerTransportOpen::DESCRIPTOR, + PeerTransportSend::DESCRIPTOR, + PeerTransportRecv::DESCRIPTOR, + PeerTransportReset::DESCRIPTOR, + PeerTransportClose::DESCRIPTOR, + PeerTransportEvents::DESCRIPTOR, PermissionsRequestDevicePermission::DESCRIPTOR, PermissionsRequestRemotePermission::DESCRIPTOR, PermissionsAuthorizeRemotePermission::DESCRIPTOR, @@ -2352,6 +2548,13 @@ pub const WIDGET_METHODS: &[MethodDescriptor] = &[ PaymentRequest::DESCRIPTOR, PaymentStatusSubscribe::DESCRIPTOR, PaymentTopUp::DESCRIPTOR, + PeerTransportDial::DESCRIPTOR, + PeerTransportOpen::DESCRIPTOR, + PeerTransportSend::DESCRIPTOR, + PeerTransportRecv::DESCRIPTOR, + PeerTransportReset::DESCRIPTOR, + PeerTransportClose::DESCRIPTOR, + PeerTransportEvents::DESCRIPTOR, PermissionsRequestDevicePermission::DESCRIPTOR, PermissionsRequestRemotePermission::DESCRIPTOR, PermissionsAuthorizeRemotePermission::DESCRIPTOR, @@ -2432,6 +2635,13 @@ pub const WORKER_METHODS: &[MethodDescriptor] = &[ PaymentRequest::DESCRIPTOR, PaymentStatusSubscribe::DESCRIPTOR, PaymentTopUp::DESCRIPTOR, + PeerTransportDial::DESCRIPTOR, + PeerTransportOpen::DESCRIPTOR, + PeerTransportSend::DESCRIPTOR, + PeerTransportRecv::DESCRIPTOR, + PeerTransportReset::DESCRIPTOR, + PeerTransportClose::DESCRIPTOR, + PeerTransportEvents::DESCRIPTOR, PermissionsRequestDevicePermission::DESCRIPTOR, PermissionsRequestRemotePermission::DESCRIPTOR, PermissionsAuthorizeRemotePermission::DESCRIPTOR, diff --git a/rust/crates/truapi-codegen/src/rust.rs b/rust/crates/truapi-codegen/src/rust.rs index 8040d6db66..5380815522 100644 --- a/rust/crates/truapi-codegen/src/rust.rs +++ b/rust/crates/truapi-codegen/src/rust.rs @@ -65,6 +65,7 @@ const TRAIT_MODULE_MAP: &[(&str, &str)] = &[ ("JsonRpc", "jsonrpc"), ("LocalStorage", "local_storage"), ("Payment", "payment"), + ("PeerTransport", "peer_transport"), ("Permissions", "permissions"), ("Preimage", "preimage"), ("Renderer", "renderer"), diff --git a/rust/crates/truapi-server/src/core.rs b/rust/crates/truapi-server/src/core.rs index 3072890d12..f8845c4ce9 100644 --- a/rust/crates/truapi-server/src/core.rs +++ b/rust/crates/truapi-server/src/core.rs @@ -187,6 +187,52 @@ mod tests { use crate::frame::{Payload, request_ids, subscription_ids}; use crate::test_support::{StubPlatform, runtime_config, test_spawner}; + #[test] + fn a_published_product_has_no_implicit_peer_transport_grant() { + let (host_config, product) = runtime_config("dotli.dot"); + let core = TrUApiCore::from_platform_with_config( + Arc::new(StubPlatform::default()), + host_config, + product, + test_spawner(), + ); + let ids = request_ids("peer_transport_dial").expect("registered peer transport"); + let frame = ProtocolMessage { + request_id: "p:peer".into(), + payload: Payload { + trait_id: ids.trait_id, + method_id: ids.method_id, + message_type: crate::frame::MESSAGE_TYPE_REQUEST, + value: truapi::versioned::peer_transport::HostPeerTransportDialRequest::V1( + truapi::latest::HostPeerTransportDialRequest { + genesis: [0x35; 32], + ip: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1], + port: 43000, + ed25519: [0; 32], + p256: None, + }, + ) + .encode(), + }, + }; + let response = futures::executor::block_on(core.receive_from_product(&frame.encode())) + .expect("registered method must answer explicitly"); + assert_eq!( + ProtocolMessage::decode(&mut &response[..]) + .unwrap() + .payload + .value, + Err::( + truapi::CallError::Domain( + truapi::versioned::peer_transport::HostPeerTransportDialError::V1( + truapi::latest::HostPeerTransportDialError::NotGranted, + ), + ), + ) + .encode(), + ); + } + /// A request payload must consume exactly its own bytes. Trailing bytes /// mean the sender and this build disagree about the shape, so running the /// handler on the prefix would act on a frame neither side agreed to. diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index 342da1b4c9..536021e1da 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -32,6 +32,7 @@ pub(crate) mod host_core; pub mod host_logic; pub(crate) mod host_rpc_client; pub mod logging; +pub mod peer_transport; pub(crate) mod runtime; pub mod subscription; pub mod transport; diff --git a/rust/crates/truapi-server/src/peer_transport.rs b/rust/crates/truapi-server/src/peer_transport.rs new file mode 100644 index 0000000000..85a2be798d --- /dev/null +++ b/rust/crates/truapi-server/src/peer_transport.rs @@ -0,0 +1,128 @@ +//! A genesis-bound JAM peer-transport grant, not a general network capability. +//! +//! The app manifest (`$v` 2) declares `capabilities.network.jam = { genesis }`. +//! A host that honours the declaration constructs a [`PeerTransportGrant`] +//! from the manifest it actually loaded, never from a guest request, and +//! accepts `PeerTransport::dial` only for that genesis. Everything else stays +//! [`NotGranted`](truapi::latest::HostPeerTransportDialError::NotGranted), +//! and the grant is revoked when the execution stops. +//! +//! The host also owns the transport: it builds the JAMNP-S ALPN from the +//! genesis ([`PeerTransportGrant::alpn`]), verifies the peer certificate +//! against the identity the guest named, frames messages and enforces the +//! `PEER_TRANSPORT_MAX_*` caps from `truapi::latest`. + +use core::fmt; + +/// Manifest schema version that carries `capabilities.network.jam`. +pub const MANIFEST_SCHEMA_VERSION: u64 = 2; +/// JAMNP-S ALPN prefix; the suffix is the first eight hex nibbles of the genesis +/// header hash. +pub const ALPN_PREFIX: &str = "jamnp-s/1/"; + +/// Explicit genesis-bound authority derived from the loaded manifest. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PeerTransportGrant { + /// Genesis header hash the guest may dial peers of. + pub genesis: [u8; 32], +} + +/// Invalid manifest capability. No grant is created on failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum PeerTransportGrantError { + /// The manifest is not a JSON object or is not schema version 2. + #[error("manifest must be a schema-version-2 JSON object")] + InvalidManifest, + /// `capabilities.network.jam` is present but not an object with a `genesis`. + #[error("capabilities.network.jam must be an object with a genesis")] + InvalidCapability, + /// The genesis is not a 32-byte lowercase hex hash. + #[error("capabilities.network.jam.genesis must be 32 bytes of lowercase hex")] + InvalidGenesis, +} + +impl PeerTransportGrant { + /// Read the grant from the loaded manifest bytes. + /// + /// `Ok(None)` means the manifest declares no JAM network capability, so the + /// host must leave every `PeerTransport` method at its `NotGranted` default. + /// Unknown manifest fields are ignored; only the capability itself is + /// validated here. + pub fn from_manifest(manifest_json: &[u8]) -> Result, PeerTransportGrantError> { + let manifest: serde_json::Value = serde_json::from_slice(manifest_json) + .map_err(|_| PeerTransportGrantError::InvalidManifest)?; + let object = manifest + .as_object() + .ok_or(PeerTransportGrantError::InvalidManifest)?; + if object.get("$v").and_then(serde_json::Value::as_u64) != Some(MANIFEST_SCHEMA_VERSION) { + return Err(PeerTransportGrantError::InvalidManifest); + } + let Some(jam) = object + .get("capabilities") + .and_then(|capabilities| capabilities.get("network")) + .and_then(|network| network.get("jam")) + else { + return Ok(None); + }; + let genesis = jam + .as_object() + .and_then(|jam| jam.get("genesis")) + .ok_or(PeerTransportGrantError::InvalidCapability)? + .as_str() + .ok_or(PeerTransportGrantError::InvalidGenesis)?; + Ok(Some(Self { + genesis: parse_genesis(genesis)?, + })) + } + + /// Whether a `dial` naming `genesis` is within this grant. + pub fn permits(&self, genesis: &[u8; 32]) -> bool { + self.genesis == *genesis + } + + /// The JAMNP-S ALPN protocol id for the granted genesis. + pub fn alpn(&self) -> String { + alpn(&self.genesis) + } +} + +impl fmt::Display for PeerTransportGrant { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "jam:")?; + for byte in self.genesis { + write!(f, "{byte:02x}")?; + } + Ok(()) + } +} + +/// The JAMNP-S ALPN protocol id for `genesis`: +/// `jamnp-s/1/`. +pub fn alpn(genesis: &[u8; 32]) -> String { + let mut alpn = String::with_capacity(ALPN_PREFIX.len() + 8); + alpn.push_str(ALPN_PREFIX); + for byte in &genesis[..4] { + use fmt::Write as _; + write!(alpn, "{byte:02x}").expect("String never fails to write"); + } + alpn +} + +/// Parse a manifest genesis: exactly 64 lowercase hex digits, with or without a +/// `0x` prefix. Uppercase is refused so one hash has one spelling. +pub fn parse_genesis(text: &str) -> Result<[u8; 32], PeerTransportGrantError> { + let hex = text.strip_prefix("0x").unwrap_or(text); + if hex.len() != 64 { + return Err(PeerTransportGrantError::InvalidGenesis); + } + let mut genesis = [0u8; 32]; + for (index, pair) in hex.as_bytes().as_chunks::<2>().0.iter().enumerate() { + let nibble = |byte: u8| match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'a'..=b'f' => Ok(byte - b'a' + 10), + _ => Err(PeerTransportGrantError::InvalidGenesis), + }; + genesis[index] = (nibble(pair[0])? << 4) | nibble(pair[1])?; + } + Ok(genesis) +} diff --git a/rust/crates/truapi-server/src/runtime/capabilities/resources.rs b/rust/crates/truapi-server/src/runtime/capabilities/resources.rs index 2a39d4142b..9b8975e0df 100644 --- a/rust/crates/truapi-server/src/runtime/capabilities/resources.rs +++ b/rust/crates/truapi-server/src/runtime/capabilities/resources.rs @@ -17,6 +17,10 @@ use crate::runtime::{ remote_authority_call, remote_authority_context_with_default, }; +// Published product runtimes have no JAM peer-transport grant. +#[truapi::async_trait] +impl truapi::api::PeerTransport for ProductRuntimeHost {} + #[truapi::async_trait] impl ResourceAllocation for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "resource_allocation.request"))] diff --git a/rust/crates/truapi-server/tests/peer_transport_grant.rs b/rust/crates/truapi-server/tests/peer_transport_grant.rs new file mode 100644 index 0000000000..18b20d3c6d --- /dev/null +++ b/rust/crates/truapi-server/tests/peer_transport_grant.rs @@ -0,0 +1,207 @@ +use parity_scale_codec::Encode; +use truapi::latest; +use truapi::versioned::peer_transport; +use truapi_server::generated::wire_table::{ + MethodIds, PEER_TRANSPORT_CLOSE, PEER_TRANSPORT_DIAL, PEER_TRANSPORT_EVENTS, + PEER_TRANSPORT_OPEN, PEER_TRANSPORT_RECV, PEER_TRANSPORT_RESET, PEER_TRANSPORT_SEND, +}; +use truapi_server::peer_transport::{PeerTransportGrant, PeerTransportGrantError, alpn}; + +const GENESIS_HEX: &str = "353963b9cedfe4ea22038081052a5c151b06b55a4a026a97522cd0320cabf49f"; + +fn genesis() -> [u8; 32] { + truapi_server::peer_transport::parse_genesis(GENESIS_HEX).unwrap() +} + +fn manifest(capabilities: &str) -> Vec { + format!( + r#"{{"$v":2,"kind":"app","appVersion":[0,1,0],"runtime":{{"kind":"polkavm","abiVersion":1,"entrypoint":"app.polkavm"}},"capabilities":{capabilities}}}"# + ) + .into_bytes() +} + +#[test] +fn the_manifest_capability_grants_exactly_its_genesis() { + let grant = PeerTransportGrant::from_manifest(&manifest(&format!( + r#"{{"graphics":{{"abiVersion":1,"profile":"framebuffer"}},"network":{{"jam":{{"genesis":"{GENESIS_HEX}"}}}}}}"# + ))) + .unwrap() + .expect("capability present"); + assert_eq!(grant.genesis, genesis()); + assert!(grant.permits(&genesis())); + assert!(!grant.permits(&[0; 32])); + assert_eq!(grant.alpn(), "jamnp-s/1/353963b9"); + assert_eq!(alpn(&[0xab; 32]), "jamnp-s/1/abababab"); + assert_eq!(grant.to_string(), format!("jam:{GENESIS_HEX}")); + + let prefixed = PeerTransportGrant::from_manifest(&manifest(&format!( + r#"{{"network":{{"jam":{{"genesis":"0x{GENESIS_HEX}"}}}}}}"# + ))) + .unwrap(); + assert_eq!(prefixed, Some(grant)); +} + +#[test] +fn a_manifest_without_the_capability_grants_nothing() { + for capabilities in [ + r#"{"graphics":{"abiVersion":1,"profile":"framebuffer"}}"#, + r#"{"network":{}}"#, + r#"{"network":{"http":{"origins":["https://example.invalid"]}}}"#, + ] { + assert_eq!( + PeerTransportGrant::from_manifest(&manifest(capabilities)).unwrap(), + None, + "{capabilities}" + ); + } +} + +#[test] +fn a_malformed_capability_is_refused_rather_than_ignored() { + for (capabilities, error) in [ + ( + r#"{"network":{"jam":true}}"#.to_string(), + PeerTransportGrantError::InvalidCapability, + ), + ( + r#"{"network":{"jam":{}}}"#.to_string(), + PeerTransportGrantError::InvalidCapability, + ), + ( + r#"{"network":{"jam":{"genesis":7}}}"#.to_string(), + PeerTransportGrantError::InvalidGenesis, + ), + ( + format!( + r#"{{"network":{{"jam":{{"genesis":"{}"}}}}}}"#, + &GENESIS_HEX[..62] + ), + PeerTransportGrantError::InvalidGenesis, + ), + ( + format!( + r#"{{"network":{{"jam":{{"genesis":"{}"}}}}}}"#, + GENESIS_HEX.to_uppercase() + ), + PeerTransportGrantError::InvalidGenesis, + ), + ( + format!(r#"{{"network":{{"jam":{{"genesis":"{GENESIS_HEX}0"}}}}}}"#), + PeerTransportGrantError::InvalidGenesis, + ), + ] { + assert_eq!( + PeerTransportGrant::from_manifest(&manifest(&capabilities)), + Err(error), + "{capabilities}" + ); + } + assert_eq!( + PeerTransportGrant::from_manifest(br#"{"$v":1,"trustedProducts":{}}"#), + Err(PeerTransportGrantError::InvalidManifest) + ); + assert_eq!( + PeerTransportGrant::from_manifest(b"[]"), + Err(PeerTransportGrantError::InvalidManifest) + ); + assert_eq!( + PeerTransportGrant::from_manifest(b"{"), + Err(PeerTransportGrantError::InvalidManifest) + ); +} + +/// The frozen contract: namespace 21, methods 0..6 in this order, V1 payloads. +#[test] +fn the_wire_ids_and_scale_layout_match_the_frozen_contract() { + for (ids, method_id) in [ + (PEER_TRANSPORT_DIAL, 0), + (PEER_TRANSPORT_OPEN, 1), + (PEER_TRANSPORT_SEND, 2), + (PEER_TRANSPORT_RECV, 3), + (PEER_TRANSPORT_RESET, 4), + (PEER_TRANSPORT_CLOSE, 5), + (PEER_TRANSPORT_EVENTS, 6), + ] { + assert_eq!( + ids, + MethodIds { + trait_id: 21, + method_id + } + ); + } + + let dial = + peer_transport::HostPeerTransportDialRequest::V1(latest::HostPeerTransportDialRequest { + genesis: genesis(), + ip: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1], + port: 43000, + ed25519: [0x11; 32], + p256: Some([0x02; 33]), + }) + .encode(); + let mut expected = vec![0u8]; + expected.extend_from_slice(&genesis()); + expected.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1]); + expected.extend_from_slice(&43000u16.to_le_bytes()); + expected.extend_from_slice(&[0x11; 32]); + expected.push(1); + expected.extend_from_slice(&[0x02; 33]); + assert_eq!(dial, expected); + + assert_eq!( + peer_transport::HostPeerTransportSendRequest::V1(latest::HostPeerTransportSendRequest { + stream: 7, + message: vec![0xaa, 0xbb], + fin: true, + }) + .encode(), + vec![0, 7, 0, 0, 0, 8, 0xaa, 0xbb, 1] + ); + assert_eq!( + peer_transport::HostPeerTransportRecvResponse::V1(latest::HostPeerTransportRecvResponse { + message: None, + fin: false, + reset: true, + }) + .encode(), + vec![0, 0, 0, 1] + ); + assert_eq!( + peer_transport::HostPeerTransportEventsResponse::V1( + latest::HostPeerTransportEventsResponse { + events: vec![ + latest::PeerTransportEvent::ConnClosed { conn: 1 }, + latest::PeerTransportEvent::StreamFin { stream: 2 }, + latest::PeerTransportEvent::Accepted { + conn: 1, + stream: 3, + kind: 0, + }, + ], + } + ) + .encode(), + vec![ + 0, 12, 0, 1, 0, 0, 0, 1, 2, 0, 0, 0, 2, 1, 0, 0, 0, 3, 0, 0, 0, 0 + ] + ); + assert_eq!( + peer_transport::HostPeerTransportDialError::V1( + latest::HostPeerTransportDialError::Unreachable + ) + .encode(), + vec![0, 3] + ); + assert_eq!( + peer_transport::HostPeerTransportEventsRequest::V1.encode(), + vec![0] + ); + assert_eq!(latest::PEER_TRANSPORT_MAX_CONNECTIONS, 8); + assert_eq!(latest::PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION, 16); + assert_eq!(latest::PEER_TRANSPORT_MAX_MESSAGE_BYTES, 1 << 20); + assert_eq!( + latest::PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION, + 4 << 20 + ); +} diff --git a/rust/crates/truapi/src/api.rs b/rust/crates/truapi/src/api.rs index 9c01d39f42..563283740b 100644 --- a/rust/crates/truapi/src/api.rs +++ b/rust/crates/truapi/src/api.rs @@ -9,6 +9,7 @@ pub mod local_storage; pub mod locale; pub mod notifications; pub mod payment; +pub mod peer_transport; pub mod permissions; pub mod pocket; pub mod preimage; @@ -29,6 +30,7 @@ pub use local_storage::LocalStorage; pub use locale::Locale; pub use notifications::Notifications; pub use payment::Payment; +pub use peer_transport::PeerTransport; pub use permissions::Permissions; pub use pocket::Pocket; pub use preimage::Preimage; @@ -51,6 +53,7 @@ pub trait TrUApi: + Locale + Notifications + Payment + + PeerTransport + Permissions + Pocket + Preimage @@ -76,6 +79,7 @@ impl TrUApi for T where + Locale + Notifications + Payment + + PeerTransport + Permissions + Pocket + Preimage diff --git a/rust/crates/truapi/src/api/peer_transport.rs b/rust/crates/truapi/src/api/peer_transport.rs new file mode 100644 index 0000000000..69c654cffc --- /dev/null +++ b/rust/crates/truapi/src/api/peer_transport.rs @@ -0,0 +1,149 @@ +//! Unified [`PeerTransport`] trait. + +use crate::versioned::peer_transport::{ + HostPeerTransportCloseError, HostPeerTransportCloseRequest, HostPeerTransportCloseResponse, + HostPeerTransportDialError, HostPeerTransportDialRequest, HostPeerTransportDialResponse, + HostPeerTransportEventsError, HostPeerTransportEventsRequest, HostPeerTransportEventsResponse, + HostPeerTransportOpenError, HostPeerTransportOpenRequest, HostPeerTransportOpenResponse, + HostPeerTransportRecvError, HostPeerTransportRecvRequest, HostPeerTransportRecvResponse, + HostPeerTransportResetError, HostPeerTransportResetRequest, HostPeerTransportResetResponse, + HostPeerTransportSendError, HostPeerTransportSendRequest, HostPeerTransportSendResponse, +}; +use crate::{CallContext, CallError, v01, wire, wire_trait}; + +/// Host-terminated QUIC/WebTransport streams to JAM peers (JAMNP-S). +/// +/// The host owns TLS, certificate verification and length framing; the guest +/// verifies every byte it consumes. Access requires the manifest capability +/// `capabilities.network.jam = { genesis }` and is granted only for that +/// genesis. A grant is separate from account, signing and storage authority. +#[wire_trait(id = 21)] +#[crate::async_trait] +pub trait PeerTransport: Send + Sync { + /// Dial one peer. The host builds the ALPN from `genesis` and requires the + /// peer certificate to carry `ed25519` (QUIC) or to hash to the + /// certificate derived from `p256` (WebTransport). + /// + /// ```ts + /// const result = await truapi.peerTransport.dial({ + /// genesis: "0x353963b9cedfe4ea22038081052a5c151b06b55a4a026a97522cd0320cabf49f", + /// ip: "0x00000000000000000000ffff7f000001", + /// port: 43000, + /// ed25519: "0x0000000000000000000000000000000000000000000000000000000000000000", + /// p256: undefined, + /// }); + /// if (result.isOk()) console.log("connection:", result.value.conn); + /// ``` + #[wire(id = 0)] + async fn dial( + &self, + _cx: &CallContext, + _request: HostPeerTransportDialRequest, + ) -> Result> { + Err(CallError::Domain(HostPeerTransportDialError::V1( + v01::HostPeerTransportDialError::NotGranted, + ))) + } + + /// Open a bidirectional stream on a connection and send its kind byte. + /// + /// ```ts + /// const result = await truapi.peerTransport.open({ conn: 0, kind: 0 }); + /// if (result.isOk()) console.log("stream:", result.value.stream); + /// ``` + #[wire(id = 1)] + async fn open( + &self, + _cx: &CallContext, + _request: HostPeerTransportOpenRequest, + ) -> Result> { + Err(CallError::Domain(HostPeerTransportOpenError::V1( + v01::HostPeerTransportOpenError::NotGranted, + ))) + } + + /// Queue one message; the host prepends the `u32` little-endian length. + /// + /// ```ts + /// const result = await truapi.peerTransport.send({ stream: 0, message: "0x00", fin: false }); + /// console.log("sent:", result.isOk()); + /// ``` + #[wire(id = 2)] + async fn send( + &self, + _cx: &CallContext, + _request: HostPeerTransportSendRequest, + ) -> Result> { + Err(CallError::Domain(HostPeerTransportSendError::V1( + v01::HostPeerTransportSendError::Closed, + ))) + } + + /// Poll one complete message without blocking; the host strips the length. + /// + /// ```ts + /// const result = await truapi.peerTransport.recv({ stream: 0, max: 1048576 }); + /// if (result.isOk()) console.log("message:", result.value.message, "fin:", result.value.fin); + /// ``` + #[wire(id = 3)] + async fn recv( + &self, + _cx: &CallContext, + _request: HostPeerTransportRecvRequest, + ) -> Result> { + Err(CallError::Domain(HostPeerTransportRecvError::V1( + v01::HostPeerTransportRecvError::Closed, + ))) + } + + /// Abort a stream in both directions. + /// + /// ```ts + /// const result = await truapi.peerTransport.reset({ stream: 0 }); + /// console.log("reset:", result.isOk()); + /// ``` + #[wire(id = 4)] + async fn reset( + &self, + _cx: &CallContext, + _request: HostPeerTransportResetRequest, + ) -> Result> { + Err(CallError::Domain(HostPeerTransportResetError::V1( + v01::HostPeerTransportResetError::Closed, + ))) + } + + /// Close a connection and every stream on it. + /// + /// ```ts + /// const result = await truapi.peerTransport.close({ conn: 0 }); + /// console.log("closed:", result.isOk()); + /// ``` + #[wire(id = 5)] + async fn close( + &self, + _cx: &CallContext, + _request: HostPeerTransportCloseRequest, + ) -> Result> { + Err(CallError::Domain(HostPeerTransportCloseError::V1( + v01::HostPeerTransportCloseError::Closed, + ))) + } + + /// Drain connection, stream-finish and inbound-stream events. + /// + /// ```ts + /// const result = await truapi.peerTransport.events(); + /// if (result.isOk()) console.log("events:", result.value.events); + /// ``` + #[wire(id = 6)] + async fn events( + &self, + _cx: &CallContext, + _request: HostPeerTransportEventsRequest, + ) -> Result> { + Err(CallError::Domain(HostPeerTransportEventsError::V1( + v01::HostPeerTransportEventsError::NotGranted, + ))) + } +} diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 3ea0eb28be..ac4a610467 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -76,16 +76,24 @@ pub mod latest { HostAccountCreateProofRequest, HostAccountGetAliasRequest, HostAccountListRingVrfKeysRequest, HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest, HostAccountSignVrfError, HostAccountSignVrfRequest, - HostPlatform, HostSignPayloadData, HostWorkerOperationError, ImageFit, ImageProps, - ImageSource, Modifier, NotificationId, OperationId, OperationStartedResult, PocketCard, - ProductAccountId, ProductProofContext, RawPayload, RegisteredRingVrfKey, RemotePermission, - RemoteStatementStoreCreateProofError, RemoteStatementStoreCreateProofRequest, - RemoteStatementStoreCreateProofResponse, RemoteStatementStoreSubscribeItem, - RemoteStatementStoreSubscribeRequest, RenderContext, RendererNode, RingLocation, - RingLocationJunction, RingVrfKeyDisclosure, RingVrfPublicKey, RowProps, RuntimeApi, - RuntimeSpec, RuntimeType, Shape, SignedStatement, Size, Statement, StatementProof, - StorageQueryItem, StorageQueryType, StorageResultItem, TextFieldProps, TextProps, - ThemeName, ThemeVariant, TxPayloadExtension, TypographyStyle, VerticalAlignment, + HostPeerTransportCloseError, HostPeerTransportCloseRequest, HostPeerTransportDialError, + HostPeerTransportDialRequest, HostPeerTransportDialResponse, HostPeerTransportEventsError, + HostPeerTransportEventsResponse, HostPeerTransportOpenError, HostPeerTransportOpenRequest, + HostPeerTransportOpenResponse, HostPeerTransportRecvError, HostPeerTransportRecvRequest, + HostPeerTransportRecvResponse, HostPeerTransportResetError, HostPeerTransportResetRequest, + HostPeerTransportSendError, HostPeerTransportSendRequest, HostPlatform, + HostSignPayloadData, HostWorkerOperationError, ImageFit, ImageProps, ImageSource, Modifier, + NotificationId, OperationId, OperationStartedResult, + PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION, PEER_TRANSPORT_MAX_CONNECTIONS, + PEER_TRANSPORT_MAX_MESSAGE_BYTES, PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION, + PeerTransportEvent, PocketCard, ProductAccountId, ProductProofContext, RawPayload, + RegisteredRingVrfKey, RemotePermission, RemoteStatementStoreCreateProofError, + RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, + RemoteStatementStoreSubscribeItem, RemoteStatementStoreSubscribeRequest, RenderContext, + RendererNode, RingLocation, RingLocationJunction, RingVrfKeyDisclosure, RingVrfPublicKey, + RowProps, RuntimeApi, RuntimeSpec, RuntimeType, Shape, SignedStatement, Size, Statement, + StatementProof, StorageQueryItem, StorageQueryType, StorageResultItem, TextFieldProps, + TextProps, ThemeName, ThemeVariant, TxPayloadExtension, TypographyStyle, VerticalAlignment, VrfSignature, }; diff --git a/rust/crates/truapi/src/v01.rs b/rust/crates/truapi/src/v01.rs index cec1d7f015..acba68ff3d 100644 --- a/rust/crates/truapi/src/v01.rs +++ b/rust/crates/truapi/src/v01.rs @@ -10,6 +10,7 @@ mod local_storage; mod locale; mod notifications; mod payment; +mod peer_transport; mod permissions; mod pocket; mod preimage; @@ -32,6 +33,7 @@ pub use local_storage::*; pub use locale::*; pub use notifications::*; pub use payment::*; +pub use peer_transport::*; pub use permissions::*; pub use pocket::*; pub use preimage::*; diff --git a/rust/crates/truapi/src/v01/peer_transport.rs b/rust/crates/truapi/src/v01/peer_transport.rs new file mode 100644 index 0000000000..f20c9fe319 --- /dev/null +++ b/rust/crates/truapi/src/v01/peer_transport.rs @@ -0,0 +1,191 @@ +use alloc::vec::Vec; +use parity_scale_codec::{Decode, Encode}; + +/// Host-side limits every `PeerTransport` implementation enforces. +pub const PEER_TRANSPORT_MAX_CONNECTIONS: u32 = 8; +/// Streams one execution may hold open per connection. +pub const PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION: u32 = 16; +/// Largest framed message accepted by `send` or delivered by `recv`. +pub const PEER_TRANSPORT_MAX_MESSAGE_BYTES: u32 = 1 << 20; +/// Bytes the host buffers per connection before applying back-pressure. +pub const PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION: u32 = 4 << 20; + +/// Failure to dial a JAM peer. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostPeerTransportDialError { + /// This execution has no peer-transport grant for the requested genesis. + NotGranted, + /// The peer refused the connection or presented a certificate that does + /// not match the requested identity. + Refused, + /// The connection cap for this execution is exhausted. + Limit, + /// The endpoint could not be reached. + Unreachable, +} + +/// Dial one JAM peer over JAMNP-S (QUIC) or WebTransport. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostPeerTransportDialRequest { + /// Genesis header hash; the host derives the ALPN from it and requires a + /// matching manifest grant. + pub genesis: [u8; 32], + /// Peer IP address, IPv6 or v4-mapped IPv6. + pub ip: [u8; 16], + /// Peer UDP port. + pub port: u16, + /// Ed25519 key the peer's TLS certificate must carry. + pub ed25519: [u8; 32], + /// Compressed P-256 peer key for WebTransport certificate hashes. + pub p256: Option<[u8; 33]>, +} + +/// An open connection handle. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostPeerTransportDialResponse { + /// Execution-local connection id. + pub conn: u32, +} + +/// Failure to open a stream. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostPeerTransportOpenError { + /// This execution has no peer-transport grant. + NotGranted, + /// The connection is closed or unknown. + Closed, + /// The stream cap for this connection is exhausted. + Limit, +} + +/// Open a bidirectional stream and send its kind byte. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostPeerTransportOpenRequest { + /// Connection returned by `dial`. + pub conn: u32, + /// JAMNP-S stream kind (UP 0, CE 128, ...). + pub kind: u8, +} + +/// An open stream handle. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostPeerTransportOpenResponse { + /// Execution-local stream id. + pub stream: u32, +} + +/// Failure to send a message. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostPeerTransportSendError { + /// The stream is closed, finished or unknown. + Closed, + /// The message exceeds the host's message limit. + TooLarge, + /// The per-connection buffer is full. + Limit, +} + +/// Send one framed message; the host adds the `u32` little-endian length. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostPeerTransportSendRequest { + /// Stream returned by `open` or reported by an `Accepted` event. + pub stream: u32, + /// Message bytes without length prefix. + pub message: Vec, + /// Finish the send side after this message. + pub fin: bool, +} + +/// Failure to receive from a stream. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostPeerTransportRecvError { + /// The stream is unknown or already fully consumed. + Closed, +} + +/// Poll one complete framed message without blocking. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostPeerTransportRecvRequest { + /// Stream to read from. + pub stream: u32, + /// Largest message the caller accepts. + pub max: u32, +} + +/// One unframed message, or none available yet. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostPeerTransportRecvResponse { + /// Complete message bytes without length prefix, or `None` when nothing + /// has arrived yet. + pub message: Option>, + /// The peer finished its send side; no further messages will arrive. + pub fin: bool, + /// The peer reset the stream; buffered data may be incomplete. + pub reset: bool, +} + +/// Failure to reset a stream. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostPeerTransportResetError { + /// The stream is unknown or already closed. + Closed, +} + +/// Abort both directions of a stream. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostPeerTransportResetRequest { + /// Stream to reset. + pub stream: u32, +} + +/// Failure to close a connection. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostPeerTransportCloseError { + /// The connection is unknown or already closed. + Closed, +} + +/// Close a connection and every stream on it. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostPeerTransportCloseRequest { + /// Connection to close. + pub conn: u32, +} + +/// Failure to drain events. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostPeerTransportEventsError { + /// This execution has no peer-transport grant. + NotGranted, +} + +/// Asynchronous transport notification. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum PeerTransportEvent { + /// The connection was closed by the peer or the host. + ConnClosed { + /// Connection that closed. + conn: u32, + }, + /// The peer finished its send side of a stream. + StreamFin { + /// Stream that finished. + stream: u32, + }, + /// The peer opened a stream to us on a dialed connection. + Accepted { + /// Connection the stream arrived on. + conn: u32, + /// Execution-local stream id. + stream: u32, + /// Stream kind byte the peer sent. + kind: u8, + }, +} + +/// Events in arrival order. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostPeerTransportEventsResponse { + /// Pending events; empty when nothing happened. + pub events: Vec, +} diff --git a/rust/crates/truapi/src/versioned.rs b/rust/crates/truapi/src/versioned.rs index cdc222bfa4..e2c808b49b 100644 --- a/rust/crates/truapi/src/versioned.rs +++ b/rust/crates/truapi/src/versioned.rs @@ -43,6 +43,7 @@ pub mod local_storage; pub mod locale; pub mod notifications; pub mod payment; +pub mod peer_transport; pub mod permissions; pub mod pocket; pub mod preimage; diff --git a/rust/crates/truapi/src/versioned/peer_transport.rs b/rust/crates/truapi/src/versioned/peer_transport.rs new file mode 100644 index 0000000000..c80553282a --- /dev/null +++ b/rust/crates/truapi/src/versioned/peer_transport.rs @@ -0,0 +1,27 @@ +//! Versioned wrappers for [`PeerTransport`](crate::api::PeerTransport) methods. + +use crate::v01; + +truapi_macros::versioned_type! { + pub enum HostPeerTransportDialRequest { V1 => v01::HostPeerTransportDialRequest } + pub enum HostPeerTransportDialResponse { V1 => v01::HostPeerTransportDialResponse } + pub enum HostPeerTransportDialError { V1 => v01::HostPeerTransportDialError } + pub enum HostPeerTransportOpenRequest { V1 => v01::HostPeerTransportOpenRequest } + pub enum HostPeerTransportOpenResponse { V1 => v01::HostPeerTransportOpenResponse } + pub enum HostPeerTransportOpenError { V1 => v01::HostPeerTransportOpenError } + pub enum HostPeerTransportSendRequest { V1 => v01::HostPeerTransportSendRequest } + pub enum HostPeerTransportSendResponse { V1 } + pub enum HostPeerTransportSendError { V1 => v01::HostPeerTransportSendError } + pub enum HostPeerTransportRecvRequest { V1 => v01::HostPeerTransportRecvRequest } + pub enum HostPeerTransportRecvResponse { V1 => v01::HostPeerTransportRecvResponse } + pub enum HostPeerTransportRecvError { V1 => v01::HostPeerTransportRecvError } + pub enum HostPeerTransportResetRequest { V1 => v01::HostPeerTransportResetRequest } + pub enum HostPeerTransportResetResponse { V1 } + pub enum HostPeerTransportResetError { V1 => v01::HostPeerTransportResetError } + pub enum HostPeerTransportCloseRequest { V1 => v01::HostPeerTransportCloseRequest } + pub enum HostPeerTransportCloseResponse { V1 } + pub enum HostPeerTransportCloseError { V1 => v01::HostPeerTransportCloseError } + pub enum HostPeerTransportEventsRequest { V1 } + pub enum HostPeerTransportEventsResponse { V1 => v01::HostPeerTransportEventsResponse } + pub enum HostPeerTransportEventsError { V1 => v01::HostPeerTransportEventsError } +} From 81021f4326f2701f7286012a1b5dba570a70d73a Mon Sep 17 00:00:00 2001 From: w Date: Sat, 26 Sep 2026 18:59:00 -0400 Subject: [PATCH 02/36] refactor(truapi): move PeerTransport to wire trait 23 Contacts (#17) and Game (#990) both claim trait 20, so whichever lands second is expected to take 21, and Profile holds 22. 23 keeps PeerTransport clear of all three before any guest bakes the id in. --- .changeset/pvm-peer-transport.md | 2 +- .../truapi/src/peer-transport-wire.test.ts | 8 ++++---- rust/crates/truapi-client/src/generated.rs | 16 ++++++++-------- .../truapi-server/tests/peer_transport_grant.rs | 4 ++-- rust/crates/truapi/src/api/peer_transport.rs | 2 +- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.changeset/pvm-peer-transport.md b/.changeset/pvm-peer-transport.md index a9253fc589..908ca1bb0a 100644 --- a/.changeset/pvm-peer-transport.md +++ b/.changeset/pvm-peer-transport.md @@ -3,7 +3,7 @@ "@parity/truapi-host": minor --- -Add the `PeerTransport` host service (trait 21): host-terminated JAMNP-S QUIC or WebTransport streams to JAM peers +Add the `PeerTransport` host service (trait 23): host-terminated JAMNP-S QUIC or WebTransport streams to JAM peers with `dial`, `open`, `send`, `recv`, `reset`, `close` and `events`. A host may grant it only to an execution whose App manifest (`$v` 2) declares `capabilities.network.jam = { genesis }`, and only for that genesis; the default implementation, including the Rust product runtime, returns `NotGranted`. Ships the browser WebTransport adapter and diff --git a/js/packages/truapi/src/peer-transport-wire.test.ts b/js/packages/truapi/src/peer-transport-wire.test.ts index 8fdf2b53f9..eee980c19b 100644 --- a/js/packages/truapi/src/peer-transport-wire.test.ts +++ b/js/packages/truapi/src/peer-transport-wire.test.ts @@ -17,11 +17,11 @@ import { decodeWireMessage, encodeWireMessage } from "./transport.js"; const GENESIS = "0x353963b9cedfe4ea22038081052a5c151b06b55a4a026a97522cd0320cabf49f" as const; const LOOPBACK_V4_MAPPED = "0x00000000000000000000ffff7f000001" as const; -test("PeerTransport is namespace 21 with methods 0..6 in contract order", () => { +test("PeerTransport is namespace 23 with methods 0..6 in contract order", () => { const ids = [PEER_TRANSPORT_DIAL, PEER_TRANSPORT_OPEN, PEER_TRANSPORT_SEND, PEER_TRANSPORT_RECV, PEER_TRANSPORT_RESET, PEER_TRANSPORT_CLOSE, PEER_TRANSPORT_EVENTS]; ids.forEach((id, method) => { - expect(id.trait).toBe(21); + expect(id.trait).toBe(23); expect(id.method).toBe(method); expect(id.kind).toBe("request"); }); @@ -78,9 +78,9 @@ test("send, recv and events payloads match the Rust SCALE bytes", () => { test("a NotGranted dial response decodes from a host frame", () => { const resultCodec = S.Result(T.VersionedHostPeerTransportDialResponse, S.CallError(T.VersionedHostPeerTransportDialError)); const value = resultCodec.enc({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "NotGranted" } } }); - const frame = encodeWireMessage({ requestId: "p", payload: { traitId: 21, methodId: 0, messageType: 1, value } }); + const frame = encodeWireMessage({ requestId: "p", payload: { traitId: 23, methodId: 0, messageType: 1, value } }); if (frame.isErr()) throw frame.error; - expect([...frame.value]).toEqual([4, 112, 21, 0, 1, 1, 0, 0, 0]); + expect([...frame.value]).toEqual([4, 112, 23, 0, 1, 1, 0, 0, 0]); const decoded = decodeWireMessage(frame.value); if (decoded.isErr()) throw decoded.error; expect(decoded.value.requestId).toBe("p"); diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index aa62b391d0..5150036c5a 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "4260ce2fcc2d5cfe"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "27b9c7f113a9e96c"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; @@ -1399,7 +1399,7 @@ impl PeerTransportDial { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 21, + trait_id: 23, method_id: 0, }), }; @@ -1426,7 +1426,7 @@ impl PeerTransportOpen { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 21, + trait_id: 23, method_id: 1, }), }; @@ -1453,7 +1453,7 @@ impl PeerTransportSend { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 21, + trait_id: 23, method_id: 2, }), }; @@ -1480,7 +1480,7 @@ impl PeerTransportRecv { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 21, + trait_id: 23, method_id: 3, }), }; @@ -1507,7 +1507,7 @@ impl PeerTransportReset { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 21, + trait_id: 23, method_id: 4, }), }; @@ -1534,7 +1534,7 @@ impl PeerTransportClose { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 21, + trait_id: 23, method_id: 5, }), }; @@ -1561,7 +1561,7 @@ impl PeerTransportEvents { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 21, + trait_id: 23, method_id: 6, }), }; diff --git a/rust/crates/truapi-server/tests/peer_transport_grant.rs b/rust/crates/truapi-server/tests/peer_transport_grant.rs index 18b20d3c6d..ebdbdf8529 100644 --- a/rust/crates/truapi-server/tests/peer_transport_grant.rs +++ b/rust/crates/truapi-server/tests/peer_transport_grant.rs @@ -110,7 +110,7 @@ fn a_malformed_capability_is_refused_rather_than_ignored() { ); } -/// The frozen contract: namespace 21, methods 0..6 in this order, V1 payloads. +/// The frozen contract: namespace 23, methods 0..6 in this order, V1 payloads. #[test] fn the_wire_ids_and_scale_layout_match_the_frozen_contract() { for (ids, method_id) in [ @@ -125,7 +125,7 @@ fn the_wire_ids_and_scale_layout_match_the_frozen_contract() { assert_eq!( ids, MethodIds { - trait_id: 21, + trait_id: 23, method_id } ); diff --git a/rust/crates/truapi/src/api/peer_transport.rs b/rust/crates/truapi/src/api/peer_transport.rs index 69c654cffc..57854cad54 100644 --- a/rust/crates/truapi/src/api/peer_transport.rs +++ b/rust/crates/truapi/src/api/peer_transport.rs @@ -17,7 +17,7 @@ use crate::{CallContext, CallError, v01, wire, wire_trait}; /// verifies every byte it consumes. Access requires the manifest capability /// `capabilities.network.jam = { genesis }` and is granted only for that /// genesis. A grant is separate from account, signing and storage authority. -#[wire_trait(id = 21)] +#[wire_trait(id = 23)] #[crate::async_trait] pub trait PeerTransport: Send + Sync { /// Dial one peer. The host builds the ALPN from `genesis` and requires the From 4f5a7504e077d7d136105f4878029377384ec9c8 Mon Sep 17 00:00:00 2001 From: w Date: Sun, 27 Sep 2026 07:29:32 -0400 Subject: [PATCH 03/36] feat(truapi): grant PeerTransport by the JamPeers runtime permission Peer access is now a runtime permission rather than an App-manifest capability, matching the rest of TrUAPI (Remote, WebRtc, ChainSubmit). RemotePermission gains JamPeers { genesis }, appended as variant 5 so every earlier index is unchanged; its decision is stored per product and genesis like any other remote permission. ProductRuntimeHost::require_jam_peers checks the stored decision, prompts while it is undetermined and persists the answer, keeping a one-use grant for the execution so a light client dialing several validators is asked once per genesis. The product runtime's PeerTransport still answers NotGranted. The manifest grant and its parser are gone; the genesis parser and ALPN helper stay. The browser session takes an authorize(genesis) callback instead of a fixed genesis, asks once per genesis per session and shares a pending answer between concurrent dials. The Android and iOS product bridges deny JamPeers without a prompt, as neither ships a JAM transport. --- .changeset/pvm-peer-transport.md | 10 +- docs/rfcs/0002-permission-model.md | 12 +- .../domain/truapi/ProductTrUAPIHostBridge.kt | 13 +- .../TrUAPI/RustProductExecutionBridge.swift | 12 +- .../truapi/src/peer-transport-wire.test.ts | 10 +- js/packages/truapi/src/peer-transport.test.ts | 97 ++++++++++++-- js/packages/truapi/src/peer-transport.ts | 54 +++++--- rust/crates/truapi-client/src/generated.rs | 2 +- rust/crates/truapi-platform/src/lib.rs | 18 +++ rust/crates/truapi-platform/src/mock.rs | 3 +- .../truapi-server/src/peer_transport.rs | 110 +++------------ rust/crates/truapi-server/src/runtime.rs | 45 +++++++ .../crates/truapi-server/src/runtime/tests.rs | 124 +++++++++++++++++ ...rt_grant.rs => peer_transport_contract.rs} | 126 ++++++------------ rust/crates/truapi/src/api/peer_transport.rs | 10 +- rust/crates/truapi/src/v01/peer_transport.rs | 5 +- rust/crates/truapi/src/v01/permissions.rs | 22 ++- 17 files changed, 441 insertions(+), 232 deletions(-) rename rust/crates/truapi-server/tests/{peer_transport_grant.rs => peer_transport_contract.rs} (53%) diff --git a/.changeset/pvm-peer-transport.md b/.changeset/pvm-peer-transport.md index 908ca1bb0a..93a654cc15 100644 --- a/.changeset/pvm-peer-transport.md +++ b/.changeset/pvm-peer-transport.md @@ -4,7 +4,9 @@ --- Add the `PeerTransport` host service (trait 23): host-terminated JAMNP-S QUIC or WebTransport streams to JAM peers -with `dial`, `open`, `send`, `recv`, `reset`, `close` and `events`. A host may grant it only to an execution whose -App manifest (`$v` 2) declares `capabilities.network.jam = { genesis }`, and only for that genesis; the default -implementation, including the Rust product runtime, returns `NotGranted`. Ships the browser WebTransport adapter and -the deterministic PolkaJAM certificate-hash derivation under `@parity/truapi/peer-transport`. +with `dial`, `open`, `send`, `recv`, `reset`, `close` and `events`. Access is the runtime permission +`RemotePermission::JamPeers { genesis }`, appended as variant 5: before a `dial` connects, the host checks the +product's stored decision, prompts when it is undetermined and persists the answer per product and genesis. App +manifests declare nothing. The default implementation, including the Rust product runtime, returns `NotGranted`. +Ships the browser WebTransport adapter, whose `createPeerTransportSession({ authorize })` asks once per genesis per +session, and the deterministic PolkaJAM certificate-hash derivation under `@parity/truapi/peer-transport`. diff --git a/docs/rfcs/0002-permission-model.md b/docs/rfcs/0002-permission-model.md index 8931f038f4..6a59e4fdc8 100644 --- a/docs/rfcs/0002-permission-model.md +++ b/docs/rfcs/0002-permission-model.md @@ -136,7 +136,11 @@ enum RemotePermission { PreimageSubmit, // Submit statements to the statement store via // remote_statement_store_submit. - StatementSubmit + StatementSubmit, + // Read-only JAMNP-S QUIC/WebTransport peer access to the validators of + // one JAM chain via peer_transport_dial. The product names the endpoints; + // every byte received is untrusted. Decided per product and genesis. + JamPeers { genesis: [u8; 32] } } ``` @@ -235,6 +239,7 @@ The following business methods gate on a specific permission and MUST internally | `remote_chain_transaction_broadcast` | `RemotePermission::ChainSubmit` | | `remote_preimage_submit` | `RemotePermission::PreimageSubmit` | | `remote_statement_store_submit` | `RemotePermission::StatementSubmit` | +| `peer_transport_dial` | `RemotePermission::JamPeers { genesis }` | | `host_navigate_to` | `DevicePermission::OpenUrl` | | `send_push_notification` | `DevicePermission::Notifications` | @@ -278,7 +283,8 @@ enum RemotePermission { WebRTC, ChainSubmit, PreimageSubmit, - StatementSubmit + StatementSubmit, + JamPeers { genesis: [u8; 32] } } // Single-permission device request (unchanged semantics, updated type name) @@ -325,7 +331,7 @@ This RFC introduces breaking changes: 3. **New `DevicePermission` variants**: `NFC`, `Clipboard`, `OpenUrl`, and `Biometrics` are new enum variants appended after the existing four. Older hosts that receive an unrecognized variant SHOULD return `false` (permission not granted) rather than an error, to allow graceful degradation. -4. **New `RemotePermission` variants**: `PreimageSubmit` and `StatementSubmit` are new variants. Older hosts that receive an unrecognized variant in a batch SHOULD treat it as denied and return `false`. +4. **New `RemotePermission` variants**: `PreimageSubmit`, `StatementSubmit` and `JamPeers` are new variants, appended so earlier indices are unchanged. Older hosts that receive an unrecognized variant in a batch SHOULD treat it as denied and return `false`. Migration is straightforward for implementors following semantic versioning: bump the major version, update type names, and wrap single-permission calls in a `vec![...]`. diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt index 3efea7e9d4..9c4c9e3160 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt @@ -198,11 +198,14 @@ class ProductTrUAPIHostBridge @AssistedInject constructor( override suspend fun remotePermission( product: ProductExecutionConfig, request: RemotePermission, - ): TrUAPIPermissionDecision = - hostApiInteractor - .requestRemotePermissionDecision(callingProductId, request.toDomain()) + ): TrUAPIPermissionDecision { + // This app has no JAM peer transport, so it has nothing to grant. + val domainRequest = request.toDomain() ?: return TrUAPIPermissionDecision.DENY + return hostApiInteractor + .requestRemotePermissionDecision(callingProductId, domainRequest) .getOrElse { throw it } .toNative() + } /** * Answered from the same snapshot [chainConnect] dials rather than the @@ -364,12 +367,14 @@ private fun HostDevicePermissionRequest.toCapability(): DeviceCapabilityType = w HostDevicePermissionRequest.BIOMETRICS -> DeviceCapabilityType.Biometrics } -private fun RemotePermission.toDomain(): RemotePermissionRequest = when (this) { +/** The Products domain request, or `null` for a permission this app has no surface for. */ +private fun RemotePermission.toDomain(): RemotePermissionRequest? = when (this) { is RemotePermission.Remote -> RemotePermissionRequest.Remote(domains) RemotePermission.WebRtc -> RemotePermissionRequest.WebRtc RemotePermission.ChainSubmit -> RemotePermissionRequest.ChainSubmit RemotePermission.PreimageSubmit -> RemotePermissionRequest.PreimageSubmit RemotePermission.StatementSubmit -> RemotePermissionRequest.StatementSubmit + is RemotePermission.JamPeers -> null } private fun PermissionDecision.toNative(): TrUAPIPermissionDecision = when (this) { diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift index 09d3c3d46d..9ddb11536a 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift @@ -97,9 +97,11 @@ class RustProductExecutionBridge: HostBridge, @unchecked Sendable { product _: ProductExecutionConfig, request: RemotePermission ) async throws -> TrUAPIPermissionDecision { - try await dependencies.permissionGuard.requestPermissionsDecision( + // This app has no JAM peer transport, so it has nothing to grant. + guard let domainRequest = request.toDomainRequest() else { return .deny } + return try await dependencies.permissionGuard.requestPermissionsDecision( productId: dependencies.productId, - permissions: request.toDomainRequest().toDomainPermissions() + permissions: domainRequest.toDomainPermissions() ).hostDecision } @@ -230,14 +232,16 @@ extension HostDevicePermissionRequest { } extension RemotePermission { - /// Maps the TrUAPI remote permission to the Products domain request. - func toDomainRequest() -> Products.RemotePermissionRequest { + /// Maps the TrUAPI remote permission to the Products domain request, or + /// `nil` for one this app has no surface for. + func toDomainRequest() -> Products.RemotePermissionRequest? { switch self { case let .remote(domains): .remote(domains: domains) case .webRtc: .webRTC case .chainSubmit: .chainSubmit case .preimageSubmit: .preimageSubmit case .statementSubmit: .statementSubmit + case .jamPeers: nil } } } diff --git a/js/packages/truapi/src/peer-transport-wire.test.ts b/js/packages/truapi/src/peer-transport-wire.test.ts index eee980c19b..48715818f4 100644 --- a/js/packages/truapi/src/peer-transport-wire.test.ts +++ b/js/packages/truapi/src/peer-transport-wire.test.ts @@ -12,7 +12,7 @@ import { } from "./generated/wire-table.js"; import { decodeWireMessage, encodeWireMessage } from "./transport.js"; -// The same bytes `rust/crates/truapi-server/tests/peer_transport_grant.rs` +// The same bytes `rust/crates/truapi-server/tests/peer_transport_contract.rs` // pins for the Rust SCALE codec: both sides must agree on the frozen V1 layout. const GENESIS = "0x353963b9cedfe4ea22038081052a5c151b06b55a4a026a97522cd0320cabf49f" as const; const LOOPBACK_V4_MAPPED = "0x00000000000000000000ffff7f000001" as const; @@ -27,6 +27,14 @@ test("PeerTransport is namespace 23 with methods 0..6 in contract order", () => }); }); +test("the JamPeers permission is RemotePermission index 5 carrying the genesis, as in Rust", () => { + const permission = { tag: "JamPeers", value: { genesis: GENESIS } } as const; + const encoded = T.RemotePermission.enc(permission); + expect([...encoded]).toEqual([5, ...S.hexToBytes(GENESIS)]); + expect(T.RemotePermission.dec(encoded)).toEqual(permission); + expect([...T.RemotePermission.enc({ tag: "StatementSubmit" })]).toEqual([4]); +}); + test("dial request encodes genesis, v4-mapped ip, port, ed25519 and optional p256 as Rust does", () => { const encoded = T.VersionedHostPeerTransportDialRequest.enc({ tag: "V1", diff --git a/js/packages/truapi/src/peer-transport.test.ts b/js/packages/truapi/src/peer-transport.test.ts index 66b008304f..7f36c7691f 100644 --- a/js/packages/truapi/src/peer-transport.test.ts +++ b/js/packages/truapi/src/peer-transport.test.ts @@ -114,10 +114,15 @@ function dialRequest(overrides: Partial = {}): U }); } -async function negotiated(options: { failReady?: boolean } = {}): Promise<{ session: PeerTransportSession; transports: FakeTransport[] }> { +const OTHER_GENESIS = `0x${"ab".repeat(32)}`; +const NOT_GRANTED = { success: false, value: { tag: "Domain", value: { tag: "V1", value: "NotGranted" } } }; + +async function negotiated( + options: { failReady?: boolean; authorize?: (genesis: string) => Promise } = {}, +): Promise<{ session: PeerTransportSession; transports: FakeTransport[] }> { const transports: FakeTransport[] = []; const session = createPeerTransportSession({ - genesis: GENESIS, + authorize: options.authorize ?? (async (genesis) => genesis === GENESIS), now: () => 1_790_380_800, connect: (url, hashes) => { const transport = fakeTransport(url, hashes, options.failReady); @@ -151,17 +156,91 @@ describe("peerUrl", () => { }); }); -describe("grant", () => { - test("dial before the handshake is NotGranted", async () => { - const session = createPeerTransportSession({ genesis: GENESIS, connect: () => fakeTransport("", []) }); +describe("authorization", () => { + test("dial before the handshake is NotGranted and asks nothing", async () => { + const asked: string[] = []; + const session = createPeerTransportSession({ + authorize: async (genesis) => { + asked.push(genesis); + return true; + }, + connect: () => fakeTransport("", []), + }); const dial = await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); - expect(dial).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "NotGranted" } } }); + expect(dial).toEqual(NOT_GRANTED); + expect(asked).toEqual([]); + }); + + test("a granted genesis is asked once for the whole session", async () => { + const asked: string[] = []; + const { session, transports } = await negotiated({ + authorize: async (genesis) => { + asked.push(genesis); + return true; + }, + }); + for (let i = 0; i < 3; i++) { + expect((await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + } + expect(asked).toEqual([GENESIS]); + expect(transports).toHaveLength(3); + }); + + test("a denied or failed decision is NotGranted, remembered, and connects nothing", async () => { + const asked: string[] = []; + const { session, transports } = await negotiated({ + authorize: async (genesis) => { + asked.push(genesis); + if (genesis === OTHER_GENESIS) throw new Error("prompt dismissed"); + return false; + }, + }); + for (let i = 0; i < 2; i++) { + expect(await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).toEqual(NOT_GRANTED); + expect(await call(session, PEER_TRANSPORT_DIAL, dialRequest({ genesis: OTHER_GENESIS }), dialCodec)).toEqual(NOT_GRANTED); + } + expect(asked).toEqual([GENESIS, OTHER_GENESIS]); + expect(transports).toHaveLength(0); + }); + + test("concurrent dials share one pending decision per genesis", async () => { + const asked: string[] = []; + const pending = new Map void>(); + const { session, transports } = await negotiated({ + authorize: (genesis) => { + asked.push(genesis); + const { promise, resolve } = Promise.withResolvers(); + pending.set(genesis, resolve); + return promise; + }, + }); + const granted = [0, 1, 2].map(() => call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)); + const refused = [0, 1].map(() => call(session, PEER_TRANSPORT_DIAL, dialRequest({ genesis: OTHER_GENESIS }), dialCodec)); + await tick(); + expect(asked).toEqual([GENESIS, OTHER_GENESIS]); + expect(transports).toHaveLength(0); + + pending.get(GENESIS)!(true); + pending.get(OTHER_GENESIS)!(false); + expect((await Promise.all(granted)).map((dial) => dial.success)).toEqual([true, true, true]); + expect(await Promise.all(refused)).toEqual([NOT_GRANTED, NOT_GRANTED]); + expect(asked).toEqual([GENESIS, OTHER_GENESIS]); + expect(transports).toHaveLength(3); + }); + + test("a session closed while the decision is pending connects nothing", async () => { + const { promise, resolve } = Promise.withResolvers(); + const { session, transports } = await negotiated({ authorize: () => promise }); + const dial = call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); + await tick(); + session.close(); + resolve(true); + expect(await dial).toEqual({ success: false, value: { tag: "Denied" } }); + expect(transports).toHaveLength(0); }); - test("dial for another genesis is NotGranted; without p256 it is Unreachable", async () => { + test("a granted dial without p256 is Unreachable in the browser", async () => { const { session, transports } = await negotiated(); - const other = await call(session, PEER_TRANSPORT_DIAL, dialRequest({ genesis: `0x${"ab".repeat(32)}` }), dialCodec); - expect(other).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "NotGranted" } } }); const quicOnly = await call(session, PEER_TRANSPORT_DIAL, dialRequest({ p256: undefined }), dialCodec); expect(quicOnly).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Unreachable" } } }); expect(transports).toHaveLength(0); diff --git a/js/packages/truapi/src/peer-transport.ts b/js/packages/truapi/src/peer-transport.ts index a7d882d612..54fdfe6098 100644 --- a/js/packages/truapi/src/peer-transport.ts +++ b/js/packages/truapi/src/peer-transport.ts @@ -57,13 +57,15 @@ export interface WebTransportBidirectionalStreamLike { readonly writable: WritableStream; } -/** A host-owned grant for one JAM genesis; the guest can neither create nor widen it. */ -export interface PeerTransportGrant { - /** `0x`-prefixed lower-case 32-byte genesis header hash. */ - genesis: string; -} - -export interface PeerTransportOptions extends PeerTransportGrant { +export interface PeerTransportOptions { + /** + * Decide whether this execution may dial peers of `genesis`, a `0x`-prefixed + * lower-case 32-byte genesis header hash: the host's check of the + * `RemotePermission::JamPeers` runtime permission. The session asks at most + * once per genesis and concurrent dials share the pending answer; `false` or + * a rejection answers `NotGranted` for the rest of the session. + */ + authorize(genesis: string): Promise; /** Host transport injection; defaults to the browser `WebTransport` constructor. */ connect?: (url: string, certificateHashes: Uint8Array[]) => WebTransportLike; /** Unix seconds used to select certificate validity periods; defaults to the wall clock. */ @@ -74,19 +76,10 @@ export interface PeerTransportOptions extends PeerTransportGrant { export interface PeerTransportSession { /** Handle one request frame; CANCEL frames return zero bytes. */ handleFrame(frame: Uint8Array): Promise; - /** Revoke the grant and close every connection on stop or replacement. */ + /** Close every connection on stop or replacement and refuse further requests. */ close(): void; } -/** Validate and normalize the manifest `capabilities.network.jam.genesis` value. */ -export function validatePeerTransportGenesis(genesis: string): string { - const hex = genesis.startsWith("0x") ? genesis.slice(2) : genesis; - if (!/^[0-9a-f]{64}$/.test(hex)) { - throw new Error("JAM genesis must be a 32-byte lower-case hex header hash"); - } - return `0x${hex}`; -} - /** Trait id of a request frame, or `undefined` when it does not decode. */ export function frameTraitId(frame: Uint8Array): number | undefined { const decoded = decodeWireMessage(frame); @@ -175,12 +168,28 @@ interface PeerConnection { } /** - * Create the browser PeerTransport endpoint for one execution. The host must - * have checked the manifest grant before calling this constructor and must - * fence late replies against execution stop or replacement. + * Create the browser PeerTransport endpoint for one execution. Every `dial` + * is authorized for its genesis through `options.authorize` before anything + * connects; the other methods act only on connections an authorized dial + * opened. The host must fence late replies against execution stop or + * replacement. */ export function createPeerTransportSession(options: PeerTransportOptions): PeerTransportSession { - const genesis = validatePeerTransportGenesis(options.genesis); + const decisions = new Map>(); + const authorized = (genesis: string): Promise => { + let decision = decisions.get(genesis); + if (decision === undefined) { + decision = (async (): Promise => { + try { + return (await options.authorize(genesis)) === true; + } catch { + return false; + } + })(); + decisions.set(genesis, decision); + } + return decision; + }; const connect = options.connect ?? ((url, hashes): WebTransportLike => @@ -311,7 +320,8 @@ export function createPeerTransportSession(options: PeerTransportOptions): PeerT }; const dial = async (request: T.HostPeerTransportDialRequest): Promise => { - if (request.genesis !== genesis) return domain(dialResult, "NotGranted"); + if (!(await authorized(request.genesis))) return domain(dialResult, "NotGranted"); + if (closed) return frameworkResult.enc({ success: false, value: { tag: "Denied" } }); if (connections.size >= PEER_TRANSPORT_MAX_CONNECTIONS) return domain(dialResult, "Limit"); // Browsers only expose WebTransport; JAMNP-S QUIC needs the P-256 identity. if (request.p256 === undefined) return domain(dialResult, "Unreachable"); diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index 5150036c5a..12b3fb7f11 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "27b9c7f113a9e96c"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "eb7589907767e84c"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 4166e4a748..4b1810f7ff 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -2605,6 +2605,14 @@ mod tests { let account_access = CoreStorageKey::account_access_authorization("product.dot", "target.dot"); let other_target = CoreStorageKey::account_access_authorization("product.dot", "other.dot"); + let jam_peers = |product_id: &str, genesis: [u8; 32]| { + CoreStorageKey::remote_permission_authorization( + product_id, + &RemotePermissionRequest { + permission: RemotePermission::JamPeers { genesis }, + }, + ) + }; assert_ne!(camera, other_product); assert_ne!(camera, remote); @@ -2613,6 +2621,16 @@ mod tests { assert_ne!(identity, other_product_identity); assert_ne!(account_access, other_target); assert_ne!(account_access, camera); + // A JAM peer decision is kept per product and per genesis. + assert_ne!(jam_peers("product.dot", [0x35; 32]), remote); + assert_ne!( + jam_peers("product.dot", [0x35; 32]), + jam_peers("product.dot", [0x36; 32]) + ); + assert_ne!( + jam_peers("product.dot", [0x35; 32]), + jam_peers("other.dot", [0x35; 32]) + ); } #[test] diff --git a/rust/crates/truapi-platform/src/mock.rs b/rust/crates/truapi-platform/src/mock.rs index c5f3879089..b72cfd8c1f 100644 --- a/rust/crates/truapi-platform/src/mock.rs +++ b/rust/crates/truapi-platform/src/mock.rs @@ -413,7 +413,7 @@ impl MockPlatform { /// /// The key is the permission's SCALE variant tag -- `"Camera"`, or /// `"Remote"` for a [`latest::RemotePermission::Remote`] whatever domains - /// it names. + /// it names, `"JamPeers"` whatever genesis. pub fn grant_permission(&self, permission: impl Into) { self.permission_decisions .lock() @@ -1061,6 +1061,7 @@ fn remote_permission_key(permission: &latest::RemotePermission) -> &'static str Permission::ChainSubmit => "ChainSubmit", Permission::PreimageSubmit => "PreimageSubmit", Permission::StatementSubmit => "StatementSubmit", + Permission::JamPeers { .. } => "JamPeers", } } diff --git a/rust/crates/truapi-server/src/peer_transport.rs b/rust/crates/truapi-server/src/peer_transport.rs index 85a2be798d..fdd9baf531 100644 --- a/rust/crates/truapi-server/src/peer_transport.rs +++ b/rust/crates/truapi-server/src/peer_transport.rs @@ -1,100 +1,28 @@ -//! A genesis-bound JAM peer-transport grant, not a general network capability. +//! JAMNP-S helpers for hosts that implement `PeerTransport`. //! -//! The app manifest (`$v` 2) declares `capabilities.network.jam = { genesis }`. -//! A host that honours the declaration constructs a [`PeerTransportGrant`] -//! from the manifest it actually loaded, never from a guest request, and -//! accepts `PeerTransport::dial` only for that genesis. Everything else stays -//! [`NotGranted`](truapi::latest::HostPeerTransportDialError::NotGranted), -//! and the grant is revoked when the execution stops. +//! Peer access is a runtime permission, not a manifest capability: before a +//! `dial` connects, the host requires +//! [`RemotePermission::JamPeers`](truapi::latest::RemotePermission::JamPeers) +//! for the requested genesis, reading the product's stored decision, prompting +//! when it is undetermined and persisting the answer per product and genesis. +//! Anything short of a grant answers +//! [`NotGranted`](truapi::latest::HostPeerTransportDialError::NotGranted). //! //! The host also owns the transport: it builds the JAMNP-S ALPN from the -//! genesis ([`PeerTransportGrant::alpn`]), verifies the peer certificate -//! against the identity the guest named, frames messages and enforces the -//! `PEER_TRANSPORT_MAX_*` caps from `truapi::latest`. +//! genesis ([`alpn`]), verifies the peer certificate against the identity the +//! guest named, frames messages and enforces the `PEER_TRANSPORT_MAX_*` caps +//! from `truapi::latest`. use core::fmt; -/// Manifest schema version that carries `capabilities.network.jam`. -pub const MANIFEST_SCHEMA_VERSION: u64 = 2; /// JAMNP-S ALPN prefix; the suffix is the first eight hex nibbles of the genesis /// header hash. pub const ALPN_PREFIX: &str = "jamnp-s/1/"; -/// Explicit genesis-bound authority derived from the loaded manifest. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PeerTransportGrant { - /// Genesis header hash the guest may dial peers of. - pub genesis: [u8; 32], -} - -/// Invalid manifest capability. No grant is created on failure. +/// A genesis spelling other than 32 bytes of lowercase hex. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] -pub enum PeerTransportGrantError { - /// The manifest is not a JSON object or is not schema version 2. - #[error("manifest must be a schema-version-2 JSON object")] - InvalidManifest, - /// `capabilities.network.jam` is present but not an object with a `genesis`. - #[error("capabilities.network.jam must be an object with a genesis")] - InvalidCapability, - /// The genesis is not a 32-byte lowercase hex hash. - #[error("capabilities.network.jam.genesis must be 32 bytes of lowercase hex")] - InvalidGenesis, -} - -impl PeerTransportGrant { - /// Read the grant from the loaded manifest bytes. - /// - /// `Ok(None)` means the manifest declares no JAM network capability, so the - /// host must leave every `PeerTransport` method at its `NotGranted` default. - /// Unknown manifest fields are ignored; only the capability itself is - /// validated here. - pub fn from_manifest(manifest_json: &[u8]) -> Result, PeerTransportGrantError> { - let manifest: serde_json::Value = serde_json::from_slice(manifest_json) - .map_err(|_| PeerTransportGrantError::InvalidManifest)?; - let object = manifest - .as_object() - .ok_or(PeerTransportGrantError::InvalidManifest)?; - if object.get("$v").and_then(serde_json::Value::as_u64) != Some(MANIFEST_SCHEMA_VERSION) { - return Err(PeerTransportGrantError::InvalidManifest); - } - let Some(jam) = object - .get("capabilities") - .and_then(|capabilities| capabilities.get("network")) - .and_then(|network| network.get("jam")) - else { - return Ok(None); - }; - let genesis = jam - .as_object() - .and_then(|jam| jam.get("genesis")) - .ok_or(PeerTransportGrantError::InvalidCapability)? - .as_str() - .ok_or(PeerTransportGrantError::InvalidGenesis)?; - Ok(Some(Self { - genesis: parse_genesis(genesis)?, - })) - } - - /// Whether a `dial` naming `genesis` is within this grant. - pub fn permits(&self, genesis: &[u8; 32]) -> bool { - self.genesis == *genesis - } - - /// The JAMNP-S ALPN protocol id for the granted genesis. - pub fn alpn(&self) -> String { - alpn(&self.genesis) - } -} - -impl fmt::Display for PeerTransportGrant { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "jam:")?; - for byte in self.genesis { - write!(f, "{byte:02x}")?; - } - Ok(()) - } -} +#[error("JAM genesis must be 32 bytes of lowercase hex")] +pub struct InvalidGenesis; /// The JAMNP-S ALPN protocol id for `genesis`: /// `jamnp-s/1/`. @@ -108,19 +36,19 @@ pub fn alpn(genesis: &[u8; 32]) -> String { alpn } -/// Parse a manifest genesis: exactly 64 lowercase hex digits, with or without a -/// `0x` prefix. Uppercase is refused so one hash has one spelling. -pub fn parse_genesis(text: &str) -> Result<[u8; 32], PeerTransportGrantError> { +/// Parse a genesis header hash: exactly 64 lowercase hex digits, with or +/// without a `0x` prefix. Uppercase is refused so one hash has one spelling. +pub fn parse_genesis(text: &str) -> Result<[u8; 32], InvalidGenesis> { let hex = text.strip_prefix("0x").unwrap_or(text); if hex.len() != 64 { - return Err(PeerTransportGrantError::InvalidGenesis); + return Err(InvalidGenesis); } let mut genesis = [0u8; 32]; for (index, pair) in hex.as_bytes().as_chunks::<2>().0.iter().enumerate() { let nibble = |byte: u8| match byte { b'0'..=b'9' => Ok(byte - b'0'), b'a'..=b'f' => Ok(byte - b'a' + 10), - _ => Err(PeerTransportGrantError::InvalidGenesis), + _ => Err(InvalidGenesis), }; genesis[index] = (nibble(pair[0])? << 4) | nibble(pair[1])?; } diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 1c81450677..fca788fe71 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -81,6 +81,7 @@ use truapi::versioned::chat::{ HostChatPostMessageError, HostChatPostMessageRequest, HostChatPostMessageResponse, HostChatRegisterBotError, HostChatRegisterBotRequest, HostChatRegisterBotResponse, }; +use truapi::versioned::peer_transport::HostPeerTransportDialError; use truapi::versioned::pocket::{ HostPocketListSubscribeError, HostPocketListSubscribeItem, HostPocketListSubscribeRequest, HostPocketRemoveCardError, HostPocketRemoveCardRequest, HostPocketRemoveCardResponse, @@ -709,6 +710,50 @@ impl ProductRuntimeHost { .await } + /// Gate `PeerTransport::dial` on + /// [`RemotePermission::JamPeers`](v01::RemotePermission::JamPeers) for + /// `genesis`, before anything connects. + /// + /// Like [`Self::require_remote_permission`], this reads the product's + /// stored decision, prompts only while it is undetermined and persists the + /// answer per product and genesis. Unlike it, a one-use grant is not spent + /// by the first dial: it lives as long as the execution's one-use grants, + /// so a light client dialing several validators of one chain is asked once + /// per genesis. Anything short of a grant, including a dismissed prompt, + /// is `NotGranted`. + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "the core has no native PeerTransport yet; its dial must call this first" + ) + )] + #[instrument(skip_all, fields(runtime.method = "peer_transport.require_jam_peers"))] + pub(crate) async fn require_jam_peers( + &self, + genesis: [u8; 32], + ) -> Result<(), CallError> { + let request = v01::RemotePermissionRequest { + permission: v01::RemotePermission::JamPeers { genesis }, + }; + match self + .permissions_service() + .check_or_prompt_remote(request) + .await + { + Ok(PermissionAuthorizationStatus::Authorized) => Ok(()), + Ok( + PermissionAuthorizationStatus::Denied + | PermissionAuthorizationStatus::NotDetermined, + ) => Err(CallError::Domain(HostPeerTransportDialError::V1( + v01::HostPeerTransportDialError::NotGranted, + ))), + Err(err) => Err(CallError::HostFailure { + reason: format!("permission storage failed: {err:?}"), + }), + } + } + #[instrument(skip_all, fields(runtime.method = "permissions.identity_disclosure_authorization"))] async fn identity_disclosure_authorization( &self, diff --git a/rust/crates/truapi-server/src/runtime/tests.rs b/rust/crates/truapi-server/src/runtime/tests.rs index 19dfbce1b0..15fd74edc8 100644 --- a/rust/crates/truapi-server/src/runtime/tests.rs +++ b/rust/crates/truapi-server/src/runtime/tests.rs @@ -1350,6 +1350,130 @@ fn permission_prompts_name_the_requesting_product_and_execution_kind() { ); } +fn jam_peers(genesis: [u8; 32]) -> v01::RemotePermissionRequest { + v01::RemotePermissionRequest { + permission: v01::RemotePermission::JamPeers { genesis }, + } +} + +fn jam_peers_not_granted() -> CallError { + CallError::Domain(HostPeerTransportDialError::V1( + v01::HostPeerTransportDialError::NotGranted, + )) +} + +#[test] +fn jam_peer_dials_follow_the_stored_decision_without_prompting() { + futures::executor::block_on(async { + let platform = stub_platform(); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + for (genesis, status) in [ + ([0x11; 32], PermissionAuthorizationStatus::Authorized), + ([0x22; 32], PermissionAuthorizationStatus::Denied), + ] { + host.set_permission_authorization_status( + PermissionAuthorizationRequest::Remote(jam_peers(genesis)), + status, + ) + .await + .unwrap(); + } + + assert_eq!( + ( + host.require_jam_peers([0x11; 32]).await, + host.require_jam_peers([0x22; 32]).await, + platform.remote_permission_requests.lock().unwrap().clone(), + ), + (Ok(()), Err(jam_peers_not_granted()), vec![]), + ); + }); +} + +#[test] +fn an_undetermined_genesis_prompts_once_per_execution() { + futures::executor::block_on(async { + let genesis = [0x35; 32]; + let platform = Arc::new(StubPlatform { + remote_permission_decisions: Mutex::new( + [ + PermissionDecision::AllowOnce, + PermissionDecision::AllowAlways, + ] + .into(), + ), + ..Default::default() + }); + // A light client dialing six validators at once is asked once, and a + // one-use answer covers the rest of the execution. + let first = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + let dials = + futures::future::join_all((0..6).map(|_| first.require_jam_peers(genesis))).await; + assert_eq!(dials, vec![Ok(()); 6]); + assert_eq!(first.require_jam_peers(genesis).await, Ok(())); + + // The next execution holds no one-use grant, so it asks again; a + // lasting answer is persisted for the product and not asked again. + let second = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + assert_eq!(second.require_jam_peers(genesis).await, Ok(())); + let third = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + assert_eq!(third.require_jam_peers(genesis).await, Ok(())); + + assert_eq!( + platform.remote_permission_requests.lock().unwrap().clone(), + vec![jam_peers(genesis), jam_peers(genesis)], + ); + }); +} + +#[test] +fn each_genesis_is_a_separate_jam_peers_decision() { + futures::executor::block_on(async { + let (granted, refused) = ([0x35; 32], [0x36; 32]); + let platform = Arc::new(StubPlatform { + remote_permission_decisions: Mutex::new( + [PermissionDecision::AllowAlways, PermissionDecision::Deny].into(), + ), + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + + assert_eq!(host.require_jam_peers(granted).await, Ok(())); + // A grant for one chain says nothing about another: it prompts, and + // the refusal is persisted for that genesis alone. + assert_eq!( + host.require_jam_peers(refused).await, + Err(jam_peers_not_granted()) + ); + assert_eq!( + host.require_jam_peers(refused).await, + Err(jam_peers_not_granted()) + ); + assert_eq!(host.require_jam_peers(granted).await, Ok(())); + + let statuses = host + .permission_authorization_statuses(vec![ + PermissionAuthorizationRequest::Remote(jam_peers(granted)), + PermissionAuthorizationRequest::Remote(jam_peers(refused)), + ]) + .await + .unwrap(); + assert_eq!( + ( + platform.remote_permission_requests.lock().unwrap().clone(), + statuses, + ), + ( + vec![jam_peers(granted), jam_peers(refused)], + vec![ + PermissionAuthorizationStatus::Authorized, + PermissionAuthorizationStatus::Denied, + ], + ), + ); + }); +} + #[test] fn navigate_to_rejects_invalid_input_without_prompting_or_calling_platform() { let platform = stub_platform(); diff --git a/rust/crates/truapi-server/tests/peer_transport_grant.rs b/rust/crates/truapi-server/tests/peer_transport_contract.rs similarity index 53% rename from rust/crates/truapi-server/tests/peer_transport_grant.rs rename to rust/crates/truapi-server/tests/peer_transport_contract.rs index ebdbdf8529..04fa49642d 100644 --- a/rust/crates/truapi-server/tests/peer_transport_grant.rs +++ b/rust/crates/truapi-server/tests/peer_transport_contract.rs @@ -1,113 +1,69 @@ -use parity_scale_codec::Encode; +//! PeerTransport contract regression test. +//! +//! Pins the frozen trait-23 wire ids and SCALE layouts, the `JamPeers` +//! permission's place in `RemotePermission`, and the genesis/ALPN helpers a +//! host uses on dial. + +use parity_scale_codec::{Decode, Encode}; use truapi::latest; use truapi::versioned::peer_transport; use truapi_server::generated::wire_table::{ MethodIds, PEER_TRANSPORT_CLOSE, PEER_TRANSPORT_DIAL, PEER_TRANSPORT_EVENTS, PEER_TRANSPORT_OPEN, PEER_TRANSPORT_RECV, PEER_TRANSPORT_RESET, PEER_TRANSPORT_SEND, }; -use truapi_server::peer_transport::{PeerTransportGrant, PeerTransportGrantError, alpn}; +use truapi_server::peer_transport::{InvalidGenesis, alpn, parse_genesis}; const GENESIS_HEX: &str = "353963b9cedfe4ea22038081052a5c151b06b55a4a026a97522cd0320cabf49f"; fn genesis() -> [u8; 32] { - truapi_server::peer_transport::parse_genesis(GENESIS_HEX).unwrap() -} - -fn manifest(capabilities: &str) -> Vec { - format!( - r#"{{"$v":2,"kind":"app","appVersion":[0,1,0],"runtime":{{"kind":"polkavm","abiVersion":1,"entrypoint":"app.polkavm"}},"capabilities":{capabilities}}}"# - ) - .into_bytes() + parse_genesis(GENESIS_HEX).unwrap() } #[test] -fn the_manifest_capability_grants_exactly_its_genesis() { - let grant = PeerTransportGrant::from_manifest(&manifest(&format!( - r#"{{"graphics":{{"abiVersion":1,"profile":"framebuffer"}},"network":{{"jam":{{"genesis":"{GENESIS_HEX}"}}}}}}"# - ))) - .unwrap() - .expect("capability present"); - assert_eq!(grant.genesis, genesis()); - assert!(grant.permits(&genesis())); - assert!(!grant.permits(&[0; 32])); - assert_eq!(grant.alpn(), "jamnp-s/1/353963b9"); +fn a_genesis_has_one_spelling_and_names_its_alpn() { + assert_eq!(parse_genesis(&format!("0x{GENESIS_HEX}")), Ok(genesis())); + assert_eq!(genesis()[..4], [0x35, 0x39, 0x63, 0xb9]); + assert_eq!(alpn(&genesis()), "jamnp-s/1/353963b9"); assert_eq!(alpn(&[0xab; 32]), "jamnp-s/1/abababab"); - assert_eq!(grant.to_string(), format!("jam:{GENESIS_HEX}")); - - let prefixed = PeerTransportGrant::from_manifest(&manifest(&format!( - r#"{{"network":{{"jam":{{"genesis":"0x{GENESIS_HEX}"}}}}}}"# - ))) - .unwrap(); - assert_eq!(prefixed, Some(grant)); -} - -#[test] -fn a_manifest_without_the_capability_grants_nothing() { - for capabilities in [ - r#"{"graphics":{"abiVersion":1,"profile":"framebuffer"}}"#, - r#"{"network":{}}"#, - r#"{"network":{"http":{"origins":["https://example.invalid"]}}}"#, + for text in [ + GENESIS_HEX[..62].to_string(), + GENESIS_HEX.to_uppercase(), + format!("{GENESIS_HEX}0"), + format!("0X{GENESIS_HEX}"), + format!("{}zz", &GENESIS_HEX[..62]), + String::new(), ] { - assert_eq!( - PeerTransportGrant::from_manifest(&manifest(capabilities)).unwrap(), - None, - "{capabilities}" - ); + assert_eq!(parse_genesis(&text), Err(InvalidGenesis), "{text}"); } } +/// `JamPeers` is appended last, so every earlier permission keeps the SCALE +/// index stored decisions and older peers already use. #[test] -fn a_malformed_capability_is_refused_rather_than_ignored() { - for (capabilities, error) in [ - ( - r#"{"network":{"jam":true}}"#.to_string(), - PeerTransportGrantError::InvalidCapability, - ), - ( - r#"{"network":{"jam":{}}}"#.to_string(), - PeerTransportGrantError::InvalidCapability, - ), +fn jam_peers_is_the_last_remote_permission_and_names_its_genesis() { + for (permission, index) in [ ( - r#"{"network":{"jam":{"genesis":7}}}"#.to_string(), - PeerTransportGrantError::InvalidGenesis, - ), - ( - format!( - r#"{{"network":{{"jam":{{"genesis":"{}"}}}}}}"#, - &GENESIS_HEX[..62] - ), - PeerTransportGrantError::InvalidGenesis, - ), - ( - format!( - r#"{{"network":{{"jam":{{"genesis":"{}"}}}}}}"#, - GENESIS_HEX.to_uppercase() - ), - PeerTransportGrantError::InvalidGenesis, - ), - ( - format!(r#"{{"network":{{"jam":{{"genesis":"{GENESIS_HEX}0"}}}}}}"#), - PeerTransportGrantError::InvalidGenesis, + latest::RemotePermission::Remote { + domains: Vec::new(), + }, + 0u8, ), + (latest::RemotePermission::WebRtc, 1), + (latest::RemotePermission::ChainSubmit, 2), + (latest::RemotePermission::PreimageSubmit, 3), + (latest::RemotePermission::StatementSubmit, 4), ] { - assert_eq!( - PeerTransportGrant::from_manifest(&manifest(&capabilities)), - Err(error), - "{capabilities}" - ); + assert_eq!(permission.encode()[0], index, "{permission:?}"); } + let jam = latest::RemotePermission::JamPeers { genesis: genesis() }; + let mut expected = vec![5u8]; + expected.extend_from_slice(&genesis()); + assert_eq!(jam.encode(), expected); assert_eq!( - PeerTransportGrant::from_manifest(br#"{"$v":1,"trustedProducts":{}}"#), - Err(PeerTransportGrantError::InvalidManifest) - ); - assert_eq!( - PeerTransportGrant::from_manifest(b"[]"), - Err(PeerTransportGrantError::InvalidManifest) - ); - assert_eq!( - PeerTransportGrant::from_manifest(b"{"), - Err(PeerTransportGrantError::InvalidManifest) + latest::RemotePermission::decode(&mut &expected[..]), + Ok(jam.clone()) ); + assert_eq!(jam.to_string(), "connections to JAM network 0x353963b9…"); } /// The frozen contract: namespace 23, methods 0..6 in this order, V1 payloads. diff --git a/rust/crates/truapi/src/api/peer_transport.rs b/rust/crates/truapi/src/api/peer_transport.rs index 57854cad54..e267589018 100644 --- a/rust/crates/truapi/src/api/peer_transport.rs +++ b/rust/crates/truapi/src/api/peer_transport.rs @@ -14,9 +14,13 @@ use crate::{CallContext, CallError, v01, wire, wire_trait}; /// Host-terminated QUIC/WebTransport streams to JAM peers (JAMNP-S). /// /// The host owns TLS, certificate verification and length framing; the guest -/// verifies every byte it consumes. Access requires the manifest capability -/// `capabilities.network.jam = { genesis }` and is granted only for that -/// genesis. A grant is separate from account, signing and storage authority. +/// verifies every byte it consumes. Access is a runtime permission, not a +/// manifest declaration: `dial` requires +/// [`RemotePermission::JamPeers`](crate::v01::RemotePermission::JamPeers) for +/// its `genesis`, checking the product's stored decision, prompting when it is +/// undetermined and persisting the answer per product and genesis. The other +/// methods act only on connections a granted `dial` opened. A grant is +/// separate from account, signing and storage authority. #[wire_trait(id = 23)] #[crate::async_trait] pub trait PeerTransport: Send + Sync { diff --git a/rust/crates/truapi/src/v01/peer_transport.rs b/rust/crates/truapi/src/v01/peer_transport.rs index f20c9fe319..3b047acdc8 100644 --- a/rust/crates/truapi/src/v01/peer_transport.rs +++ b/rust/crates/truapi/src/v01/peer_transport.rs @@ -13,7 +13,8 @@ pub const PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION: u32 = 4 << 20; /// Failure to dial a JAM peer. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum HostPeerTransportDialError { - /// This execution has no peer-transport grant for the requested genesis. + /// The product holds no `RemotePermission::JamPeers` grant for the + /// requested genesis, or this host offers no peer transport. NotGranted, /// The peer refused the connection or presented a certificate that does /// not match the requested identity. @@ -28,7 +29,7 @@ pub enum HostPeerTransportDialError { #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostPeerTransportDialRequest { /// Genesis header hash; the host derives the ALPN from it and requires a - /// matching manifest grant. + /// `RemotePermission::JamPeers` grant for it. pub genesis: [u8; 32], /// Peer IP address, IPv6 or v4-mapped IPv6. pub ip: [u8; 16], diff --git a/rust/crates/truapi/src/v01/permissions.rs b/rust/crates/truapi/src/v01/permissions.rs index 0fc09c9669..3fb1fc43a5 100644 --- a/rust/crates/truapi/src/v01/permissions.rs +++ b/rust/crates/truapi/src/v01/permissions.rs @@ -49,8 +49,9 @@ pub enum HostDevicePermissionRequest { /// One remote-operation permission requested by the product (RFC 0002). /// -/// `ChainSubmit`, `PreimageSubmit`, and `StatementSubmit` are also triggered -/// implicitly by the corresponding business calls when not yet granted. +/// `ChainSubmit`, `PreimageSubmit`, `StatementSubmit` and `JamPeers` are also +/// triggered implicitly by the corresponding business calls when not yet +/// granted (`PeerTransport::dial` for `JamPeers`). #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, Display)] #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] pub enum RemotePermission { @@ -83,6 +84,23 @@ pub enum RemotePermission { /// Submitting statements on behalf of the user via `remote_statement_store_submit`. #[display("submit statements")] StatementSubmit, + /// Read-only peer access over JAMNP-S QUIC/WebTransport to the validators + /// of one JAM chain, through the `PeerTransport` service. + /// + /// The app names the endpoints it dials; the grant covers only peers of + /// `genesis`. Every byte received is untrusted, and the grant carries no + /// account, signing or submission authority. + #[display( + "connections to JAM network 0x{:02x}{:02x}{:02x}{:02x}…", + genesis[0], + genesis[1], + genesis[2], + genesis[3] + )] + JamPeers { + /// Genesis header hash of the JAM chain whose peers may be dialed. + genesis: [u8; 32], + }, } /// remote-permission request (RFC 0002). From 8e4521b1b92be5f43d2c3d69cab2ea640fb8155e Mon Sep 17 00:00:00 2001 From: w Date: Sun, 27 Sep 2026 08:20:10 -0400 Subject: [PATCH 04/36] fix(truapi): bound PeerTransport dials and honour CANCEL A dial waiting on the JamPeers prompt could outlast the guest's request timeout; the guest retried and the original dial later opened a connection nobody knew about, holding a slot until the session closed. Dials now answer within 10 s (prompt + handshake), CANCEL withdraws an in-flight dial with Cancelled, and anything opened after withdrawal is closed without holding a slot. The permission decision is still remembered. --- js/packages/truapi/src/peer-transport.test.ts | 137 ++++++++++++++++-- js/packages/truapi/src/peer-transport.ts | 88 ++++++++--- 2 files changed, 187 insertions(+), 38 deletions(-) diff --git a/js/packages/truapi/src/peer-transport.test.ts b/js/packages/truapi/src/peer-transport.test.ts index 7f36c7691f..bad53fc588 100644 --- a/js/packages/truapi/src/peer-transport.test.ts +++ b/js/packages/truapi/src/peer-transport.test.ts @@ -11,7 +11,7 @@ import { PEER_TRANSPORT_SEND, SYSTEM_HANDSHAKE, } from "./generated/wire-table.js"; -import { decodeWireMessage, encodeWireMessage, MESSAGE_TYPE_REQUEST, type MethodIds } from "./transport.js"; +import { decodeWireMessage, encodeWireMessage, MESSAGE_TYPE_CANCEL, MESSAGE_TYPE_REQUEST, type MethodIds } from "./transport.js"; import { createPeerTransportSession, frameTraitId, @@ -38,6 +38,8 @@ interface FakeTransport extends WebTransportLike { url: string; hashes: Uint8Array[]; streams: FakeStream[]; + /** Whether the session closed this transport. */ + closedByHost: boolean; /** Simulate the peer opening a stream toward us. */ peerOpen(): FakeStream; peerClose(): void; @@ -56,15 +58,20 @@ function fakeStream(): FakeStream { }; } -function fakeTransport(url: string, hashes: Uint8Array[], failReady = false): FakeTransport { +/** `ready` settles as named; `"hang"` models a handshake that never completes. */ +type FakeReady = "ok" | "fail" | "hang"; + +function fakeTransport(url: string, hashes: Uint8Array[], ready: FakeReady = "ok"): FakeTransport { let incoming!: ReadableStreamDefaultController; const closed = Promise.withResolvers(); const streams: FakeStream[] = []; - return { + const transport: FakeTransport = { url, hashes, streams, - ready: failReady ? Promise.reject(new Error("refused")) : Promise.resolve(), + closedByHost: false, + ready: + ready === "ok" ? Promise.resolve() : ready === "fail" ? Promise.reject(new Error("refused")) : new Promise(() => undefined), closed: closed.promise, incomingBidirectionalStreams: new ReadableStream({ start: (c) => (incoming = c) }), async createBidirectionalStream() { @@ -72,7 +79,10 @@ function fakeTransport(url: string, hashes: Uint8Array[], failReady = false): Fa streams.push(stream); return stream.local; }, - close: () => closed.resolve(), + close: () => { + transport.closedByHost = true; + closed.resolve(); + }, peerOpen() { const stream = fakeStream(); streams.push(stream); @@ -81,24 +91,29 @@ function fakeTransport(url: string, hashes: Uint8Array[], failReady = false): Fa }, peerClose: () => closed.resolve(), }; + return transport; } let requestCounter = 0; -function frame(ids: MethodIds, value: Uint8Array): Uint8Array { +function frame(ids: MethodIds, value: Uint8Array, requestId = `t${requestCounter++}`, messageType = MESSAGE_TYPE_REQUEST): Uint8Array { const encoded = encodeWireMessage({ - requestId: `t${requestCounter++}`, - payload: { traitId: ids.trait, methodId: ids.method, messageType: MESSAGE_TYPE_REQUEST, value }, + requestId, + payload: { traitId: ids.trait, methodId: ids.method, messageType, value }, }); if (encoded.isErr()) throw encoded.error; return encoded.value; } -async function call(session: PeerTransportSession, ids: MethodIds, request: Uint8Array, codec: S.Codec): Promise { - const response = decodeWireMessage(await session.handleFrame(frame(ids, request))); +function decodeReply(bytes: Uint8Array, codec: S.Codec): V { + const response = decodeWireMessage(bytes); if (response.isErr()) throw response.error; return codec.dec(response.value.payload.value); } +async function call(session: PeerTransportSession, ids: MethodIds, request: Uint8Array, codec: S.Codec): Promise { + return decodeReply(await session.handleFrame(frame(ids, request)), codec); +} + const dialCodec = S.Result(T.VersionedHostPeerTransportDialResponse, S.CallError(T.VersionedHostPeerTransportDialError)); const openCodec = S.Result(T.VersionedHostPeerTransportOpenResponse, S.CallError(T.VersionedHostPeerTransportOpenError)); const sendCodec = S.Result(T.VersionedHostPeerTransportSendResponse, S.CallError(T.VersionedHostPeerTransportSendError)); @@ -118,14 +133,20 @@ const OTHER_GENESIS = `0x${"ab".repeat(32)}`; const NOT_GRANTED = { success: false, value: { tag: "Domain", value: { tag: "V1", value: "NotGranted" } } }; async function negotiated( - options: { failReady?: boolean; authorize?: (genesis: string) => Promise } = {}, + options: { + /** How the handshake of the `index`-th transport settles. */ + ready?: (index: number) => FakeReady; + authorize?: (genesis: string) => Promise; + dialTimeoutMs?: number; + } = {}, ): Promise<{ session: PeerTransportSession; transports: FakeTransport[] }> { const transports: FakeTransport[] = []; const session = createPeerTransportSession({ authorize: options.authorize ?? (async (genesis) => genesis === GENESIS), now: () => 1_790_380_800, + dialTimeoutMs: options.dialTimeoutMs, connect: (url, hashes) => { - const transport = fakeTransport(url, hashes, options.failReady); + const transport = fakeTransport(url, hashes, options.ready?.(transports.length)); transports.push(transport); return transport; }, @@ -228,14 +249,15 @@ describe("authorization", () => { expect(transports).toHaveLength(3); }); - test("a session closed while the decision is pending connects nothing", async () => { + test("closing the session answers a dial still waiting for its decision, and connects nothing", async () => { const { promise, resolve } = Promise.withResolvers(); const { session, transports } = await negotiated({ authorize: () => promise }); const dial = call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); await tick(); session.close(); - resolve(true); expect(await dial).toEqual({ success: false, value: { tag: "Denied" } }); + resolve(true); + await tick(); expect(transports).toHaveLength(0); }); @@ -268,7 +290,7 @@ describe("dial", () => { }); test("a rejected handshake is Refused and holds no connection slot", async () => { - const { session } = await negotiated({ failReady: true }); + const { session } = await negotiated({ ready: () => "fail" }); const dial = await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); expect(dial).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Refused" } } }); const close = await call(session, PEER_TRANSPORT_CLOSE, T.VersionedHostPeerTransportCloseRequest.enc({ tag: "V1", value: { conn: 1 } }), closeCodec); @@ -285,6 +307,91 @@ describe("dial", () => { }); }); +describe("withdrawn dials", () => { + const UNREACHABLE = { success: false, value: { tag: "Domain", value: { tag: "V1", value: "Unreachable" } } }; + const CANCELLED = { success: false, value: { tag: "Cancelled" } }; + const cancel = (session: PeerTransportSession, requestId: string): Promise => + session.handleFrame(frame(PEER_TRANSPORT_DIAL, new Uint8Array(), requestId, MESSAGE_TYPE_CANCEL)); + const events = async (session: PeerTransportSession): Promise => + call(session, PEER_TRANSPORT_EVENTS, T.VersionedHostPeerTransportEventsRequest.enc({ tag: "V1", value: undefined }), eventsCodec); + + test("a prompt outlasting the guest's wait opens nothing, and the retry reuses the answer", async () => { + const asked: string[] = []; + const decision = Promise.withResolvers(); + const { session, transports } = await negotiated({ + dialTimeoutMs: 20, + authorize: (genesis) => { + asked.push(genesis); + return decision.promise; + }, + }); + expect(await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).toEqual(UNREACHABLE); + // The user answers after the guest stopped waiting: that dial opens nothing. + decision.resolve(true); + await tick(); + expect(transports).toHaveLength(0); + + expect((await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + expect(asked).toEqual([GENESIS]); + expect(transports).toHaveLength(1); + }); + + test("a handshake outlasting the deadline is closed and frees its slot", async () => { + const { session, transports } = await negotiated({ dialTimeoutMs: 20, ready: (index) => (index === 0 ? "hang" : "ok") }); + expect(await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).toEqual(UNREACHABLE); + expect(transports[0]!.closedByHost).toBe(true); + for (let i = 0; i < 8; i++) { + expect((await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + } + expect(await events(session)).toEqual({ success: true, value: { tag: "V1", value: { events: [] } } }); + }); + + test("a CANCEL during the prompt answers Cancelled and opens nothing", async () => { + const decision = Promise.withResolvers(); + const { session, transports } = await negotiated({ authorize: () => decision.promise }); + const dial = session.handleFrame(frame(PEER_TRANSPORT_DIAL, dialRequest(), "d1")); + await tick(); + expect(await cancel(session, "d1")).toEqual(new Uint8Array()); + expect(decodeReply(await dial, dialCodec)).toEqual(CANCELLED); + decision.resolve(true); + await tick(); + expect(transports).toHaveLength(0); + // A CANCEL naming nothing in flight lost the race and changes nothing. + expect(await cancel(session, "d1")).toEqual(new Uint8Array()); + expect((await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + }); + + test("a CANCEL during the handshake closes the connection without consuming the cap", async () => { + const { session, transports } = await negotiated({ ready: (index) => (index === 7 ? "hang" : "ok") }); + for (let i = 0; i < 7; i++) { + expect((await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + } + const eighth = session.handleFrame(frame(PEER_TRANSPORT_DIAL, dialRequest(), "d8")); + await tick(); + expect(transports).toHaveLength(8); + await cancel(session, "d8"); + expect(decodeReply(await eighth, dialCodec)).toEqual(CANCELLED); + expect(transports[7]!.closedByHost).toBe(true); + + expect((await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + expect(await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).toEqual({ + success: false, + value: { tag: "Domain", value: { tag: "V1", value: "Limit" } }, + }); + expect(await events(session)).toEqual({ success: true, value: { tag: "V1", value: { events: [] } } }); + }); + + test("a CANCEL for a completed or unknown dial leaves its connection open", async () => { + const { session, transports } = await negotiated(); + const reply = decodeReply(await session.handleFrame(frame(PEER_TRANSPORT_DIAL, dialRequest(), "d1")), dialCodec); + expect(reply.success).toBe(true); + expect(await cancel(session, "d1")).toEqual(new Uint8Array()); + expect(await cancel(session, "unknown")).toEqual(new Uint8Array()); + expect(transports[0]!.closedByHost).not.toBe(true); + expect(await events(session)).toEqual({ success: true, value: { tag: "V1", value: { events: [] } } }); + }); +}); + describe("streams", () => { test("open sends the kind byte; send frames with a u32-LE prefix; recv unframes", async () => { const { session, transport, conn } = await dialed(); diff --git a/js/packages/truapi/src/peer-transport.ts b/js/packages/truapi/src/peer-transport.ts index 54fdfe6098..fce715b098 100644 --- a/js/packages/truapi/src/peer-transport.ts +++ b/js/packages/truapi/src/peer-transport.ts @@ -30,7 +30,12 @@ export const PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION = 4 << 20; /** Largest request frame: a `send` of a maximal message plus SCALE and wire overhead. */ export const PEER_TRANSPORT_MAX_FRAME_BYTES = PEER_TRANSPORT_MAX_MESSAGE_BYTES + 4096; const MAX_PENDING_EVENTS = 1024; -const DIAL_TIMEOUT_MS = 10_000; +/** + * Bound on one `dial`, from its arrival to its reply, the permission decision + * included. A guest that waits at least this long for a dial reply never + * misses one, and anything a dial would open after it is closed instead. + */ +export const PEER_TRANSPORT_DIAL_TIMEOUT_MS = 10_000; const textEncoder = new TextEncoder(); const handshakeResult = S.Result(T.VersionedHostHandshakeResponse, S.CallError(T.VersionedHostHandshakeError)); @@ -42,6 +47,7 @@ const recvResult = S.Result(T.VersionedHostPeerTransportRecvResponse, S.CallErro const resetResult = S.Result(T.VersionedHostPeerTransportResetResponse, S.CallError(T.VersionedHostPeerTransportResetError)); const closeResult = S.Result(T.VersionedHostPeerTransportCloseResponse, S.CallError(T.VersionedHostPeerTransportCloseError)); const eventsResult = S.Result(T.VersionedHostPeerTransportEventsResponse, S.CallError(T.VersionedHostPeerTransportEventsError)); +const cancelledReply = frameworkResult.enc({ success: false, value: { tag: "Cancelled" } }); /** Minimal WebTransport surface the session needs; lets tests inject a fake. */ export interface WebTransportLike { @@ -70,6 +76,8 @@ export interface PeerTransportOptions { connect?: (url: string, certificateHashes: Uint8Array[]) => WebTransportLike; /** Unix seconds used to select certificate validity periods; defaults to the wall clock. */ now?: () => number; + /** Dial deadline in milliseconds; defaults to {@link PEER_TRANSPORT_DIAL_TIMEOUT_MS}. */ + dialTimeoutMs?: number; } /** Execution-local peer endpoint. It provides no account or signing authority. */ @@ -171,7 +179,11 @@ interface PeerConnection { * Create the browser PeerTransport endpoint for one execution. Every `dial` * is authorized for its genesis through `options.authorize` before anything * connects; the other methods act only on connections an authorized dial - * opened. The host must fence late replies against execution stop or + * opened. A dial answers within its deadline, prompt included: one still + * waiting then answers `Unreachable`, a CANCEL naming it answers `Cancelled`, + * and in both cases whatever it opened is closed without holding a slot. The + * permission decision is remembered either way, so a retry does not ask + * again. The host must fence late replies against execution stop or * replacement. */ export function createPeerTransportSession(options: PeerTransportOptions): PeerTransportSession { @@ -197,6 +209,9 @@ export function createPeerTransportSession(options: PeerTransportOptions): PeerT serverCertificateHashes: hashes.map((value) => ({ algorithm: "sha-256", value: value as Uint8Array })), }) as unknown as WebTransportLike); const now = options.now ?? ((): number => Math.floor(Date.now() / 1000)); + const dialTimeoutMs = options.dialTimeoutMs ?? PEER_TRANSPORT_DIAL_TIMEOUT_MS; + /** In-flight dials by request id; CANCEL or `close` withdraws one with its reply. */ + const pendingDials = new Map void>(); let closed = false; let negotiated = false; let nextConn = 1; @@ -319,8 +334,15 @@ export function createPeerTransportSession(options: PeerTransportOptions): PeerT } }; - const dial = async (request: T.HostPeerTransportDialRequest): Promise => { - if (!(await authorized(request.genesis))) return domain(dialResult, "NotGranted"); + /** + * One dial. `withdrawn` settles with the reply when the guest cancels or the + * deadline passes; the guest then no longer waits for what this dial opens, + * so nothing it opens outlives it or holds a connection slot. + */ + const dial = async (request: T.HostPeerTransportDialRequest, withdrawn: Promise): Promise => { + const granted = await Promise.race([authorized(request.genesis), withdrawn]); + if (granted instanceof Uint8Array) return granted; + if (!granted) return domain(dialResult, "NotGranted"); if (closed) return frameworkResult.enc({ success: false, value: { tag: "Denied" } }); if (connections.size >= PEER_TRANSPORT_MAX_CONNECTIONS) return domain(dialResult, "Limit"); // Browsers only expose WebTransport; JAMNP-S QUIC needs the P-256 identity. @@ -335,16 +357,16 @@ export function createPeerTransportSession(options: PeerTransportOptions): PeerT } const conn: PeerConnection = { id: nextConn++, transport, streams: new Map(), closed: false }; connections.set(conn.id, conn); - // Executor form: this package's lib target predates Promise.withResolvers. - let timer: number | undefined; - try { - await Promise.race([ - transport.ready, - new Promise((_resolve, reject) => { - timer = setTimeout(() => reject(new Error("timeout")), DIAL_TIMEOUT_MS) as unknown as number; - }), - ]); - } catch (error) { + const failure = await Promise.race([ + transport.ready.then( + () => undefined, + () => domain(dialResult, "Refused"), + ), + withdrawn, + ]); + if (failure !== undefined || closed) { + // The guest never learns this connection id, so it frees its slot + // without a `ConnClosed` event. connections.delete(conn.id); conn.closed = true; try { @@ -352,13 +374,7 @@ export function createPeerTransportSession(options: PeerTransportOptions): PeerT } catch { // Never opened. } - return domain(dialResult, error instanceof Error && error.message === "timeout" ? "Unreachable" : "Refused"); - } finally { - clearTimeout(timer); - } - if (closed) { - dropConnection(conn); - return frameworkResult.enc({ success: false, value: { tag: "Denied" } }); + return failure ?? frameworkResult.enc({ success: false, value: { tag: "Denied" } }); } void transport.closed.then( () => dropConnection(conn), @@ -368,6 +384,23 @@ export function createPeerTransportSession(options: PeerTransportOptions): PeerT return ok(dialResult, { tag: "V1", value: { conn: conn.id } }); }; + /** Run one dial frame: register it for CANCEL and arm its deadline. */ + const dialFrame = async (requestId: string, request: T.HostPeerTransportDialRequest): Promise => { + // Executor form: this package's lib target predates Promise.withResolvers. + let withdraw!: (reply: Uint8Array) => void; + const withdrawn = new Promise((resolve) => { + withdraw = resolve; + }); + pendingDials.set(requestId, withdraw); + const timer = setTimeout(() => withdraw(domain(dialResult, "Unreachable")), dialTimeoutMs); + try { + return await dial(request, withdrawn); + } finally { + clearTimeout(timer); + pendingDials.delete(requestId); + } + }; + const open = async (request: T.HostPeerTransportOpenRequest): Promise => { const conn = connections.get(request.conn); if (conn === undefined || conn.closed) return domain(openResult, "Closed"); @@ -456,7 +489,10 @@ export function createPeerTransportSession(options: PeerTransportOptions): PeerT if (request.payload.traitId !== PEER_TRANSPORT_DIAL.trait || request.payload.value.length !== 0) { throw new Error("Invalid cancellation frame"); } - // Every method answers within one host tick; nothing is cancellable. + // Only a dial can outlast one host tick: it may wait on a permission + // prompt and a handshake. The dial itself answers `Cancelled`; a + // CANCEL naming nothing in flight lost the race and is dropped. + if (hasIds(request, PEER_TRANSPORT_DIAL)) pendingDials.get(request.requestId)?.(cancelledReply); return new Uint8Array(); } if (request.payload.messageType !== MESSAGE_TYPE_REQUEST) { @@ -484,7 +520,11 @@ export function createPeerTransportSession(options: PeerTransportOptions): PeerT try { if (hasIds(request, PEER_TRANSPORT_DIAL)) { const value = exact(T.VersionedHostPeerTransportDialRequest, request.payload.value).value; - return reply(request, negotiated ? await dial(value) : domain(dialResult, "NotGranted")); + if (!negotiated) return reply(request, domain(dialResult, "NotGranted")); + // Two live dials sharing an id leave neither addressable by CANCEL; + // like the core dispatcher, the second is dropped unanswered. + if (pendingDials.has(request.requestId)) return new Uint8Array(); + return reply(request, await dialFrame(request.requestId, value)); } if (hasIds(request, PEER_TRANSPORT_OPEN)) { const value = exact(T.VersionedHostPeerTransportOpenRequest, request.payload.value).value; @@ -518,6 +558,8 @@ export function createPeerTransportSession(options: PeerTransportOptions): PeerT }, close() { closed = true; + const denied = frameworkResult.enc({ success: false, value: { tag: "Denied" } }); + for (const withdraw of [...pendingDials.values()]) withdraw(denied); for (const conn of [...connections.values()]) dropConnection(conn); events.length = 0; }, From ab023e534930b5d3d4b2cf78814ef7d459df2f05 Mon Sep 17 00:00:00 2001 From: w Date: Sun, 27 Sep 2026 12:12:14 -0400 Subject: [PATCH 05/36] test(truapi-host): count the JamPeers mock permission arm --- js/packages/truapi-host/src/web/mock-host-surface.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/packages/truapi-host/src/web/mock-host-surface.test.ts b/js/packages/truapi-host/src/web/mock-host-surface.test.ts index 2180d03e94..716c3f70be 100644 --- a/js/packages/truapi-host/src/web/mock-host-surface.test.ts +++ b/js/packages/truapi-host/src/web/mock-host-surface.test.ts @@ -205,7 +205,7 @@ describe("mock host surface agreement", () => { ].map(([, variant, key]) => ({ variant, key })); // A regex that matched nothing would make this pass forever. - expect(arms.length).toBe(14); + expect(arms.length).toBe(15); expect(arms.filter(({ variant, key }) => variant !== key)).toEqual([]); }); From 50284f975459d73f3c3ea72e8716708651af7673 Mon Sep 17 00:00:00 2001 From: w Date: Sun, 27 Sep 2026 12:12:54 -0400 Subject: [PATCH 06/36] refactor(truapi): rename PeerTransport to JamPeerTransport --- ...transport.md => pvm-jam-peer-transport.md} | 6 +- docs/rfcs/0002-permission-model.md | 7 +- js/packages/truapi/package.json | 6 +- ...est.ts => jam-peer-transport-cert.test.ts} | 2 +- ...ort-cert.ts => jam-peer-transport-cert.ts} | 0 ...est.ts => jam-peer-transport-wire.test.ts} | 42 +- ...ort.test.ts => jam-peer-transport.test.ts} | 136 +++--- ...eer-transport.ts => jam-peer-transport.ts} | 122 ++--- rust/crates/truapi-client/src/generated.rs | 422 +++++++++--------- rust/crates/truapi-codegen/src/rust.rs | 2 +- rust/crates/truapi-server/src/core.rs | 14 +- ...eer_transport.rs => jam_peer_transport.rs} | 6 +- rust/crates/truapi-server/src/lib.rs | 2 +- rust/crates/truapi-server/src/runtime.rs | 14 +- .../src/runtime/capabilities/resources.rs | 2 +- .../crates/truapi-server/src/runtime/tests.rs | 6 +- ...ract.rs => jam_peer_transport_contract.rs} | 82 ++-- rust/crates/truapi/src/api.rs | 8 +- .../truapi/src/api/jam_peer_transport.rs | 158 +++++++ rust/crates/truapi/src/api/peer_transport.rs | 153 ------- rust/crates/truapi/src/lib.rs | 27 +- rust/crates/truapi/src/v01.rs | 4 +- ...eer_transport.rs => jam_peer_transport.rs} | 48 +- rust/crates/truapi/src/v01/permissions.rs | 4 +- rust/crates/truapi/src/versioned.rs | 2 +- .../src/versioned/jam_peer_transport.rs | 27 ++ .../truapi/src/versioned/peer_transport.rs | 27 -- 27 files changed, 672 insertions(+), 657 deletions(-) rename .changeset/{pvm-peer-transport.md => pvm-jam-peer-transport.md} (66%) rename js/packages/truapi/src/{peer-transport-cert.test.ts => jam-peer-transport-cert.test.ts} (99%) rename js/packages/truapi/src/{peer-transport-cert.ts => jam-peer-transport-cert.ts} (100%) rename js/packages/truapi/src/{peer-transport-wire.test.ts => jam-peer-transport-wire.test.ts} (67%) rename js/packages/truapi/src/{peer-transport.test.ts => jam-peer-transport.test.ts} (69%) rename js/packages/truapi/src/{peer-transport.ts => jam-peer-transport.ts} (80%) rename rust/crates/truapi-server/src/{peer_transport.rs => jam_peer_transport.rs} (90%) rename rust/crates/truapi-server/tests/{peer_transport_contract.rs => jam_peer_transport_contract.rs} (63%) create mode 100644 rust/crates/truapi/src/api/jam_peer_transport.rs delete mode 100644 rust/crates/truapi/src/api/peer_transport.rs rename rust/crates/truapi/src/v01/{peer_transport.rs => jam_peer_transport.rs} (81%) create mode 100644 rust/crates/truapi/src/versioned/jam_peer_transport.rs delete mode 100644 rust/crates/truapi/src/versioned/peer_transport.rs diff --git a/.changeset/pvm-peer-transport.md b/.changeset/pvm-jam-peer-transport.md similarity index 66% rename from .changeset/pvm-peer-transport.md rename to .changeset/pvm-jam-peer-transport.md index 93a654cc15..9a7747956a 100644 --- a/.changeset/pvm-peer-transport.md +++ b/.changeset/pvm-jam-peer-transport.md @@ -3,10 +3,10 @@ "@parity/truapi-host": minor --- -Add the `PeerTransport` host service (trait 23): host-terminated JAMNP-S QUIC or WebTransport streams to JAM peers +Add the `JamPeerTransport` host service (trait 23): host-terminated JAMNP-S QUIC or WebTransport streams to JAM peers with `dial`, `open`, `send`, `recv`, `reset`, `close` and `events`. Access is the runtime permission `RemotePermission::JamPeers { genesis }`, appended as variant 5: before a `dial` connects, the host checks the product's stored decision, prompts when it is undetermined and persists the answer per product and genesis. App manifests declare nothing. The default implementation, including the Rust product runtime, returns `NotGranted`. -Ships the browser WebTransport adapter, whose `createPeerTransportSession({ authorize })` asks once per genesis per -session, and the deterministic PolkaJAM certificate-hash derivation under `@parity/truapi/peer-transport`. +Ships the browser WebTransport adapter, whose `createJamPeerTransportSession({ authorize })` asks once per genesis per +session, and the deterministic PolkaJAM certificate-hash derivation under `@parity/truapi/jam-peer-transport`. diff --git a/docs/rfcs/0002-permission-model.md b/docs/rfcs/0002-permission-model.md index 6a59e4fdc8..9da6458d60 100644 --- a/docs/rfcs/0002-permission-model.md +++ b/docs/rfcs/0002-permission-model.md @@ -138,8 +138,9 @@ enum RemotePermission { // remote_statement_store_submit. StatementSubmit, // Read-only JAMNP-S QUIC/WebTransport peer access to the validators of - // one JAM chain via peer_transport_dial. The product names the endpoints; - // every byte received is untrusted. Decided per product and genesis. + // one JAM chain via jam_peer_transport_dial. The product names the + // endpoints; every byte received is untrusted. Decided per product and + // genesis. JamPeers { genesis: [u8; 32] } } ``` @@ -239,7 +240,7 @@ The following business methods gate on a specific permission and MUST internally | `remote_chain_transaction_broadcast` | `RemotePermission::ChainSubmit` | | `remote_preimage_submit` | `RemotePermission::PreimageSubmit` | | `remote_statement_store_submit` | `RemotePermission::StatementSubmit` | -| `peer_transport_dial` | `RemotePermission::JamPeers { genesis }` | +| `jam_peer_transport_dial` | `RemotePermission::JamPeers { genesis }` | | `host_navigate_to` | `DevicePermission::OpenUrl` | | `send_push_notification` | `DevicePermission::Notifications` | diff --git a/js/packages/truapi/package.json b/js/packages/truapi/package.json index 57d54abe18..945146b8a9 100644 --- a/js/packages/truapi/package.json +++ b/js/packages/truapi/package.json @@ -35,9 +35,9 @@ "types": "./dist/internal.d.ts", "import": "./dist/internal.js" }, - "./peer-transport": { - "types": "./dist/peer-transport.d.ts", - "import": "./dist/peer-transport.js" + "./jam-peer-transport": { + "types": "./dist/jam-peer-transport.d.ts", + "import": "./dist/jam-peer-transport.js" }, "./scale": { "types": "./dist/scale.d.ts", diff --git a/js/packages/truapi/src/peer-transport-cert.test.ts b/js/packages/truapi/src/jam-peer-transport-cert.test.ts similarity index 99% rename from js/packages/truapi/src/peer-transport-cert.test.ts rename to js/packages/truapi/src/jam-peer-transport-cert.test.ts index 186a0b4513..011f5b34a3 100644 --- a/js/packages/truapi/src/peer-transport-cert.test.ts +++ b/js/packages/truapi/src/jam-peer-transport-cert.test.ts @@ -10,7 +10,7 @@ import { webTransportCertificateDer, webTransportCertificateHash, webTransportCertificateHashes, -} from "./peer-transport-cert.js"; +} from "./jam-peer-transport-cert.js"; // Vectors produced by rcgen 0.14.8 / p256 0.13.2 (the PolkaJAM dd9af78 // lockfile versions) following PolkaJAM's `net/cert.rs`, at unix time diff --git a/js/packages/truapi/src/peer-transport-cert.ts b/js/packages/truapi/src/jam-peer-transport-cert.ts similarity index 100% rename from js/packages/truapi/src/peer-transport-cert.ts rename to js/packages/truapi/src/jam-peer-transport-cert.ts diff --git a/js/packages/truapi/src/peer-transport-wire.test.ts b/js/packages/truapi/src/jam-peer-transport-wire.test.ts similarity index 67% rename from js/packages/truapi/src/peer-transport-wire.test.ts rename to js/packages/truapi/src/jam-peer-transport-wire.test.ts index 48715818f4..222f795a14 100644 --- a/js/packages/truapi/src/peer-transport-wire.test.ts +++ b/js/packages/truapi/src/jam-peer-transport-wire.test.ts @@ -2,24 +2,24 @@ import { expect, test } from "bun:test"; import * as S from "./scale.js"; import * as T from "./generated/types.js"; import { - PEER_TRANSPORT_CLOSE, - PEER_TRANSPORT_DIAL, - PEER_TRANSPORT_EVENTS, - PEER_TRANSPORT_OPEN, - PEER_TRANSPORT_RECV, - PEER_TRANSPORT_RESET, - PEER_TRANSPORT_SEND, + JAM_PEER_TRANSPORT_CLOSE, + JAM_PEER_TRANSPORT_DIAL, + JAM_PEER_TRANSPORT_EVENTS, + JAM_PEER_TRANSPORT_OPEN, + JAM_PEER_TRANSPORT_RECV, + JAM_PEER_TRANSPORT_RESET, + JAM_PEER_TRANSPORT_SEND, } from "./generated/wire-table.js"; import { decodeWireMessage, encodeWireMessage } from "./transport.js"; -// The same bytes `rust/crates/truapi-server/tests/peer_transport_contract.rs` +// The same bytes `rust/crates/truapi-server/tests/jam_peer_transport_contract.rs` // pins for the Rust SCALE codec: both sides must agree on the frozen V1 layout. const GENESIS = "0x353963b9cedfe4ea22038081052a5c151b06b55a4a026a97522cd0320cabf49f" as const; const LOOPBACK_V4_MAPPED = "0x00000000000000000000ffff7f000001" as const; -test("PeerTransport is namespace 23 with methods 0..6 in contract order", () => { - const ids = [PEER_TRANSPORT_DIAL, PEER_TRANSPORT_OPEN, PEER_TRANSPORT_SEND, PEER_TRANSPORT_RECV, - PEER_TRANSPORT_RESET, PEER_TRANSPORT_CLOSE, PEER_TRANSPORT_EVENTS]; +test("JamPeerTransport is namespace 23 with methods 0..6 in contract order", () => { + const ids = [JAM_PEER_TRANSPORT_DIAL, JAM_PEER_TRANSPORT_OPEN, JAM_PEER_TRANSPORT_SEND, JAM_PEER_TRANSPORT_RECV, + JAM_PEER_TRANSPORT_RESET, JAM_PEER_TRANSPORT_CLOSE, JAM_PEER_TRANSPORT_EVENTS]; ids.forEach((id, method) => { expect(id.trait).toBe(23); expect(id.method).toBe(method); @@ -36,7 +36,7 @@ test("the JamPeers permission is RemotePermission index 5 carrying the genesis, }); test("dial request encodes genesis, v4-mapped ip, port, ed25519 and optional p256 as Rust does", () => { - const encoded = T.VersionedHostPeerTransportDialRequest.enc({ + const encoded = T.VersionedHostJamPeerTransportDialRequest.enc({ tag: "V1", value: { genesis: GENESIS, ip: LOOPBACK_V4_MAPPED, port: 43000, ed25519: `0x${"11".repeat(32)}`, p256: `0x${"02".repeat(33)}` }, }); @@ -49,26 +49,26 @@ test("dial request encodes genesis, v4-mapped ip, port, ed25519 and optional p25 1, ...new Array(33).fill(0x02), ]); - const withoutP256 = T.VersionedHostPeerTransportDialRequest.enc({ + const withoutP256 = T.VersionedHostJamPeerTransportDialRequest.enc({ tag: "V1", value: { genesis: GENESIS, ip: LOOPBACK_V4_MAPPED, port: 43000, ed25519: `0x${"11".repeat(32)}`, p256: undefined }, }); expect(withoutP256.length).toBe(1 + 32 + 16 + 2 + 32 + 1); expect(withoutP256[withoutP256.length - 1]).toBe(0); - expect(T.VersionedHostPeerTransportDialRequest.dec(withoutP256)).toEqual({ + expect(T.VersionedHostJamPeerTransportDialRequest.dec(withoutP256)).toEqual({ tag: "V1", value: { genesis: GENESIS, ip: LOOPBACK_V4_MAPPED, port: 43000, ed25519: `0x${"11".repeat(32)}`, p256: undefined }, }); }); test("send, recv and events payloads match the Rust SCALE bytes", () => { - expect([...T.VersionedHostPeerTransportSendRequest.enc({ tag: "V1", value: { stream: 7, message: "0xaabb", fin: true } })]) + expect([...T.VersionedHostJamPeerTransportSendRequest.enc({ tag: "V1", value: { stream: 7, message: "0xaabb", fin: true } })]) .toEqual([0, 7, 0, 0, 0, 8, 0xaa, 0xbb, 1]); - expect([...T.VersionedHostPeerTransportRecvResponse.enc({ tag: "V1", value: { message: undefined, fin: false, reset: true } })]) + expect([...T.VersionedHostJamPeerTransportRecvResponse.enc({ tag: "V1", value: { message: undefined, fin: false, reset: true } })]) .toEqual([0, 0, 0, 1]); - expect([...T.VersionedHostPeerTransportRecvRequest.enc({ tag: "V1", value: { stream: 3, max: 1 << 20 } })]) + expect([...T.VersionedHostJamPeerTransportRecvRequest.enc({ tag: "V1", value: { stream: 3, max: 1 << 20 } })]) .toEqual([0, 3, 0, 0, 0, 0, 0, 0x10, 0]); - const events = T.VersionedHostPeerTransportEventsResponse.enc({ + const events = T.VersionedHostJamPeerTransportEventsResponse.enc({ tag: "V1", value: { events: [ @@ -79,12 +79,12 @@ test("send, recv and events payloads match the Rust SCALE bytes", () => { }, }); expect([...events]).toEqual([0, 12, 0, 1, 0, 0, 0, 1, 2, 0, 0, 0, 2, 1, 0, 0, 0, 3, 0, 0, 0, 0]); - expect([...T.VersionedHostPeerTransportEventsRequest.enc({ tag: "V1", value: undefined })]).toEqual([0]); - expect([...T.VersionedHostPeerTransportDialError.enc({ tag: "V1", value: "Unreachable" })]).toEqual([0, 3]); + expect([...T.VersionedHostJamPeerTransportEventsRequest.enc({ tag: "V1", value: undefined })]).toEqual([0]); + expect([...T.VersionedHostJamPeerTransportDialError.enc({ tag: "V1", value: "Unreachable" })]).toEqual([0, 3]); }); test("a NotGranted dial response decodes from a host frame", () => { - const resultCodec = S.Result(T.VersionedHostPeerTransportDialResponse, S.CallError(T.VersionedHostPeerTransportDialError)); + const resultCodec = S.Result(T.VersionedHostJamPeerTransportDialResponse, S.CallError(T.VersionedHostJamPeerTransportDialError)); const value = resultCodec.enc({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "NotGranted" } } }); const frame = encodeWireMessage({ requestId: "p", payload: { traitId: 23, methodId: 0, messageType: 1, value } }); if (frame.isErr()) throw frame.error; diff --git a/js/packages/truapi/src/peer-transport.test.ts b/js/packages/truapi/src/jam-peer-transport.test.ts similarity index 69% rename from js/packages/truapi/src/peer-transport.test.ts rename to js/packages/truapi/src/jam-peer-transport.test.ts index bad53fc588..8e756d0520 100644 --- a/js/packages/truapi/src/peer-transport.test.ts +++ b/js/packages/truapi/src/jam-peer-transport.test.ts @@ -3,24 +3,24 @@ import * as S from "./scale.js"; import * as T from "./generated/types.js"; import { TRUAPI_CODEC_VERSION } from "./generated/client.js"; import { - PEER_TRANSPORT_CLOSE, - PEER_TRANSPORT_DIAL, - PEER_TRANSPORT_EVENTS, - PEER_TRANSPORT_OPEN, - PEER_TRANSPORT_RECV, - PEER_TRANSPORT_SEND, + JAM_PEER_TRANSPORT_CLOSE, + JAM_PEER_TRANSPORT_DIAL, + JAM_PEER_TRANSPORT_EVENTS, + JAM_PEER_TRANSPORT_OPEN, + JAM_PEER_TRANSPORT_RECV, + JAM_PEER_TRANSPORT_SEND, SYSTEM_HANDSHAKE, } from "./generated/wire-table.js"; import { decodeWireMessage, encodeWireMessage, MESSAGE_TYPE_CANCEL, MESSAGE_TYPE_REQUEST, type MethodIds } from "./transport.js"; import { - createPeerTransportSession, + createJamPeerTransportSession, frameTraitId, peerUrl, - PEER_TRANSPORT_MAX_MESSAGE_BYTES, - type PeerTransportSession, + JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES, + type JamPeerTransportSession, type WebTransportBidirectionalStreamLike, type WebTransportLike, -} from "./peer-transport.js"; +} from "./jam-peer-transport.js"; const GENESIS = "0x353963b9cedfe4ea22038081052a5c151b06b55a4a026a97522cd0320cabf49f"; const P256 = "0x028874174c8f469438a1b1bab2fde75f9c4999461382ec6d47e9b3b4511294c607"; @@ -110,20 +110,20 @@ function decodeReply(bytes: Uint8Array, codec: S.Codec): V { return codec.dec(response.value.payload.value); } -async function call(session: PeerTransportSession, ids: MethodIds, request: Uint8Array, codec: S.Codec): Promise { +async function call(session: JamPeerTransportSession, ids: MethodIds, request: Uint8Array, codec: S.Codec): Promise { return decodeReply(await session.handleFrame(frame(ids, request)), codec); } -const dialCodec = S.Result(T.VersionedHostPeerTransportDialResponse, S.CallError(T.VersionedHostPeerTransportDialError)); -const openCodec = S.Result(T.VersionedHostPeerTransportOpenResponse, S.CallError(T.VersionedHostPeerTransportOpenError)); -const sendCodec = S.Result(T.VersionedHostPeerTransportSendResponse, S.CallError(T.VersionedHostPeerTransportSendError)); -const recvCodec = S.Result(T.VersionedHostPeerTransportRecvResponse, S.CallError(T.VersionedHostPeerTransportRecvError)); -const closeCodec = S.Result(T.VersionedHostPeerTransportCloseResponse, S.CallError(T.VersionedHostPeerTransportCloseError)); -const eventsCodec = S.Result(T.VersionedHostPeerTransportEventsResponse, S.CallError(T.VersionedHostPeerTransportEventsError)); +const dialCodec = S.Result(T.VersionedHostJamPeerTransportDialResponse, S.CallError(T.VersionedHostJamPeerTransportDialError)); +const openCodec = S.Result(T.VersionedHostJamPeerTransportOpenResponse, S.CallError(T.VersionedHostJamPeerTransportOpenError)); +const sendCodec = S.Result(T.VersionedHostJamPeerTransportSendResponse, S.CallError(T.VersionedHostJamPeerTransportSendError)); +const recvCodec = S.Result(T.VersionedHostJamPeerTransportRecvResponse, S.CallError(T.VersionedHostJamPeerTransportRecvError)); +const closeCodec = S.Result(T.VersionedHostJamPeerTransportCloseResponse, S.CallError(T.VersionedHostJamPeerTransportCloseError)); +const eventsCodec = S.Result(T.VersionedHostJamPeerTransportEventsResponse, S.CallError(T.VersionedHostJamPeerTransportEventsError)); const handshakeCodec = S.Result(T.VersionedHostHandshakeResponse, S.CallError(T.VersionedHostHandshakeError)); -function dialRequest(overrides: Partial = {}): Uint8Array { - return T.VersionedHostPeerTransportDialRequest.enc({ +function dialRequest(overrides: Partial = {}): Uint8Array { + return T.VersionedHostJamPeerTransportDialRequest.enc({ tag: "V1", value: { genesis: GENESIS, ip: LOOPBACK, port: 43000, ed25519: `0x${"11".repeat(32)}`, p256: P256, ...overrides }, }); @@ -139,9 +139,9 @@ async function negotiated( authorize?: (genesis: string) => Promise; dialTimeoutMs?: number; } = {}, -): Promise<{ session: PeerTransportSession; transports: FakeTransport[] }> { +): Promise<{ session: JamPeerTransportSession; transports: FakeTransport[] }> { const transports: FakeTransport[] = []; - const session = createPeerTransportSession({ + const session = createJamPeerTransportSession({ authorize: options.authorize ?? (async (genesis) => genesis === GENESIS), now: () => 1_790_380_800, dialTimeoutMs: options.dialTimeoutMs, @@ -156,9 +156,9 @@ async function negotiated( return { session, transports }; } -async function dialed(): Promise<{ session: PeerTransportSession; transport: FakeTransport; conn: number }> { +async function dialed(): Promise<{ session: JamPeerTransportSession; transport: FakeTransport; conn: number }> { const { session, transports } = await negotiated(); - const dial = await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); + const dial = await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); if (!dial.success) throw new Error("dial failed"); return { session, transport: transports[0]!, conn: dial.value.value.conn }; } @@ -180,14 +180,14 @@ describe("peerUrl", () => { describe("authorization", () => { test("dial before the handshake is NotGranted and asks nothing", async () => { const asked: string[] = []; - const session = createPeerTransportSession({ + const session = createJamPeerTransportSession({ authorize: async (genesis) => { asked.push(genesis); return true; }, connect: () => fakeTransport("", []), }); - const dial = await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); + const dial = await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); expect(dial).toEqual(NOT_GRANTED); expect(asked).toEqual([]); }); @@ -201,7 +201,7 @@ describe("authorization", () => { }, }); for (let i = 0; i < 3; i++) { - expect((await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + expect((await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); } expect(asked).toEqual([GENESIS]); expect(transports).toHaveLength(3); @@ -217,8 +217,8 @@ describe("authorization", () => { }, }); for (let i = 0; i < 2; i++) { - expect(await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).toEqual(NOT_GRANTED); - expect(await call(session, PEER_TRANSPORT_DIAL, dialRequest({ genesis: OTHER_GENESIS }), dialCodec)).toEqual(NOT_GRANTED); + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).toEqual(NOT_GRANTED); + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest({ genesis: OTHER_GENESIS }), dialCodec)).toEqual(NOT_GRANTED); } expect(asked).toEqual([GENESIS, OTHER_GENESIS]); expect(transports).toHaveLength(0); @@ -235,8 +235,8 @@ describe("authorization", () => { return promise; }, }); - const granted = [0, 1, 2].map(() => call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)); - const refused = [0, 1].map(() => call(session, PEER_TRANSPORT_DIAL, dialRequest({ genesis: OTHER_GENESIS }), dialCodec)); + const granted = [0, 1, 2].map(() => call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)); + const refused = [0, 1].map(() => call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest({ genesis: OTHER_GENESIS }), dialCodec)); await tick(); expect(asked).toEqual([GENESIS, OTHER_GENESIS]); expect(transports).toHaveLength(0); @@ -252,7 +252,7 @@ describe("authorization", () => { test("closing the session answers a dial still waiting for its decision, and connects nothing", async () => { const { promise, resolve } = Promise.withResolvers(); const { session, transports } = await negotiated({ authorize: () => promise }); - const dial = call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); + const dial = call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); await tick(); session.close(); expect(await dial).toEqual({ success: false, value: { tag: "Denied" } }); @@ -263,7 +263,7 @@ describe("authorization", () => { test("a granted dial without p256 is Unreachable in the browser", async () => { const { session, transports } = await negotiated(); - const quicOnly = await call(session, PEER_TRANSPORT_DIAL, dialRequest({ p256: undefined }), dialCodec); + const quicOnly = await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest({ p256: undefined }), dialCodec); expect(quicOnly).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Unreachable" } } }); expect(transports).toHaveLength(0); }); @@ -291,18 +291,18 @@ describe("dial", () => { test("a rejected handshake is Refused and holds no connection slot", async () => { const { session } = await negotiated({ ready: () => "fail" }); - const dial = await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); + const dial = await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); expect(dial).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Refused" } } }); - const close = await call(session, PEER_TRANSPORT_CLOSE, T.VersionedHostPeerTransportCloseRequest.enc({ tag: "V1", value: { conn: 1 } }), closeCodec); + const close = await call(session, JAM_PEER_TRANSPORT_CLOSE, T.VersionedHostJamPeerTransportCloseRequest.enc({ tag: "V1", value: { conn: 1 } }), closeCodec); expect(close.success).toBe(false); }); test("the ninth connection hits Limit", async () => { const { session } = await negotiated(); for (let i = 0; i < 8; i++) { - expect((await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + expect((await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); } - const ninth = await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); + const ninth = await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); expect(ninth).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Limit" } } }); }); }); @@ -310,10 +310,10 @@ describe("dial", () => { describe("withdrawn dials", () => { const UNREACHABLE = { success: false, value: { tag: "Domain", value: { tag: "V1", value: "Unreachable" } } }; const CANCELLED = { success: false, value: { tag: "Cancelled" } }; - const cancel = (session: PeerTransportSession, requestId: string): Promise => - session.handleFrame(frame(PEER_TRANSPORT_DIAL, new Uint8Array(), requestId, MESSAGE_TYPE_CANCEL)); - const events = async (session: PeerTransportSession): Promise => - call(session, PEER_TRANSPORT_EVENTS, T.VersionedHostPeerTransportEventsRequest.enc({ tag: "V1", value: undefined }), eventsCodec); + const cancel = (session: JamPeerTransportSession, requestId: string): Promise => + session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, new Uint8Array(), requestId, MESSAGE_TYPE_CANCEL)); + const events = async (session: JamPeerTransportSession): Promise => + call(session, JAM_PEER_TRANSPORT_EVENTS, T.VersionedHostJamPeerTransportEventsRequest.enc({ tag: "V1", value: undefined }), eventsCodec); test("a prompt outlasting the guest's wait opens nothing, and the retry reuses the answer", async () => { const asked: string[] = []; @@ -325,23 +325,23 @@ describe("withdrawn dials", () => { return decision.promise; }, }); - expect(await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).toEqual(UNREACHABLE); + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).toEqual(UNREACHABLE); // The user answers after the guest stopped waiting: that dial opens nothing. decision.resolve(true); await tick(); expect(transports).toHaveLength(0); - expect((await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + expect((await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); expect(asked).toEqual([GENESIS]); expect(transports).toHaveLength(1); }); test("a handshake outlasting the deadline is closed and frees its slot", async () => { const { session, transports } = await negotiated({ dialTimeoutMs: 20, ready: (index) => (index === 0 ? "hang" : "ok") }); - expect(await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).toEqual(UNREACHABLE); + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).toEqual(UNREACHABLE); expect(transports[0]!.closedByHost).toBe(true); for (let i = 0; i < 8; i++) { - expect((await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + expect((await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); } expect(await events(session)).toEqual({ success: true, value: { tag: "V1", value: { events: [] } } }); }); @@ -349,7 +349,7 @@ describe("withdrawn dials", () => { test("a CANCEL during the prompt answers Cancelled and opens nothing", async () => { const decision = Promise.withResolvers(); const { session, transports } = await negotiated({ authorize: () => decision.promise }); - const dial = session.handleFrame(frame(PEER_TRANSPORT_DIAL, dialRequest(), "d1")); + const dial = session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, dialRequest(), "d1")); await tick(); expect(await cancel(session, "d1")).toEqual(new Uint8Array()); expect(decodeReply(await dial, dialCodec)).toEqual(CANCELLED); @@ -358,23 +358,23 @@ describe("withdrawn dials", () => { expect(transports).toHaveLength(0); // A CANCEL naming nothing in flight lost the race and changes nothing. expect(await cancel(session, "d1")).toEqual(new Uint8Array()); - expect((await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + expect((await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); }); test("a CANCEL during the handshake closes the connection without consuming the cap", async () => { const { session, transports } = await negotiated({ ready: (index) => (index === 7 ? "hang" : "ok") }); for (let i = 0; i < 7; i++) { - expect((await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + expect((await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); } - const eighth = session.handleFrame(frame(PEER_TRANSPORT_DIAL, dialRequest(), "d8")); + const eighth = session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, dialRequest(), "d8")); await tick(); expect(transports).toHaveLength(8); await cancel(session, "d8"); expect(decodeReply(await eighth, dialCodec)).toEqual(CANCELLED); expect(transports[7]!.closedByHost).toBe(true); - expect((await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); - expect(await call(session, PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).toEqual({ + expect((await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Limit" } }, }); @@ -383,7 +383,7 @@ describe("withdrawn dials", () => { test("a CANCEL for a completed or unknown dial leaves its connection open", async () => { const { session, transports } = await negotiated(); - const reply = decodeReply(await session.handleFrame(frame(PEER_TRANSPORT_DIAL, dialRequest(), "d1")), dialCodec); + const reply = decodeReply(await session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, dialRequest(), "d1")), dialCodec); expect(reply.success).toBe(true); expect(await cancel(session, "d1")).toEqual(new Uint8Array()); expect(await cancel(session, "unknown")).toEqual(new Uint8Array()); @@ -395,10 +395,10 @@ describe("withdrawn dials", () => { describe("streams", () => { test("open sends the kind byte; send frames with a u32-LE prefix; recv unframes", async () => { const { session, transport, conn } = await dialed(); - const open = await call(session, PEER_TRANSPORT_OPEN, T.VersionedHostPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 128 } }), openCodec); + const open = await call(session, JAM_PEER_TRANSPORT_OPEN, T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 128 } }), openCodec); if (!open.success) throw new Error("open failed"); const stream = open.value.value.stream; - const send = await call(session, PEER_TRANSPORT_SEND, T.VersionedHostPeerTransportSendRequest.enc({ tag: "V1", value: { stream, message: "0x0102", fin: true } }), sendCodec); + const send = await call(session, JAM_PEER_TRANSPORT_SEND, T.VersionedHostJamPeerTransportSendRequest.enc({ tag: "V1", value: { stream, message: "0x0102", fin: true } }), sendCodec); expect(send.success).toBe(true); const wire = transport.streams[0]!; expect(wire.sent.map((c) => S.bytesToHex(c))).toEqual(["0x80", "0x020000000102"]); @@ -407,33 +407,33 @@ describe("streams", () => { wire.peerWrite(new Uint8Array([3, 0, 0, 0, 0xaa])); wire.peerWrite(new Uint8Array([0xbb, 0xcc, 1, 0, 0])); await tick(); - const early = await call(session, PEER_TRANSPORT_RECV, T.VersionedHostPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 << 20 } }), recvCodec); + const early = await call(session, JAM_PEER_TRANSPORT_RECV, T.VersionedHostJamPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 << 20 } }), recvCodec); expect(early).toEqual({ success: true, value: { tag: "V1", value: { message: "0xaabbcc", fin: false, reset: false } } }); wire.peerWrite(new Uint8Array([0, 0xdd])); wire.peerFin(); await tick(); await tick(); - const second = await call(session, PEER_TRANSPORT_RECV, T.VersionedHostPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 << 20 } }), recvCodec); + const second = await call(session, JAM_PEER_TRANSPORT_RECV, T.VersionedHostJamPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 << 20 } }), recvCodec); expect(second).toEqual({ success: true, value: { tag: "V1", value: { message: "0xdd", fin: true, reset: false } } }); - const drained = await call(session, PEER_TRANSPORT_RECV, T.VersionedHostPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 << 20 } }), recvCodec); + const drained = await call(session, JAM_PEER_TRANSPORT_RECV, T.VersionedHostJamPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 << 20 } }), recvCodec); expect(drained).toEqual({ success: true, value: { tag: "V1", value: { message: undefined, fin: true, reset: false } } }); - const consumed = await call(session, PEER_TRANSPORT_RECV, T.VersionedHostPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 << 20 } }), recvCodec); + const consumed = await call(session, JAM_PEER_TRANSPORT_RECV, T.VersionedHostJamPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 << 20 } }), recvCodec); expect(consumed).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Closed" } } }); - const events = await call(session, PEER_TRANSPORT_EVENTS, T.VersionedHostPeerTransportEventsRequest.enc({ tag: "V1" }), eventsCodec); + const events = await call(session, JAM_PEER_TRANSPORT_EVENTS, T.VersionedHostJamPeerTransportEventsRequest.enc({ tag: "V1" }), eventsCodec); expect(events).toEqual({ success: true, value: { tag: "V1", value: { events: [{ tag: "StreamFin", value: { stream } }] } } }); }); test("oversized send is TooLarge and a message above the caller's max resets the stream", async () => { const { session, transport, conn } = await dialed(); - const open = await call(session, PEER_TRANSPORT_OPEN, T.VersionedHostPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + const open = await call(session, JAM_PEER_TRANSPORT_OPEN, T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); if (!open.success) throw new Error("open failed"); const stream = open.value.value.stream; - const big = `0x${"00".repeat(PEER_TRANSPORT_MAX_MESSAGE_BYTES + 1)}` as const; - const send = await call(session, PEER_TRANSPORT_SEND, T.VersionedHostPeerTransportSendRequest.enc({ tag: "V1", value: { stream, message: big, fin: false } }), sendCodec); + const big = `0x${"00".repeat(JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES + 1)}` as const; + const send = await call(session, JAM_PEER_TRANSPORT_SEND, T.VersionedHostJamPeerTransportSendRequest.enc({ tag: "V1", value: { stream, message: big, fin: false } }), sendCodec); expect(send).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "TooLarge" } } }); transport.streams[0]!.peerWrite(new Uint8Array([2, 0, 0, 0, 1, 2])); await tick(); - const recv = await call(session, PEER_TRANSPORT_RECV, T.VersionedHostPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 } }), recvCodec); + const recv = await call(session, JAM_PEER_TRANSPORT_RECV, T.VersionedHostJamPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 } }), recvCodec); expect(recv).toEqual({ success: true, value: { tag: "V1", value: { message: undefined, fin: false, reset: true } } }); }); @@ -443,25 +443,25 @@ describe("streams", () => { incoming.peerWrite(new Uint8Array([0, 2, 0, 0, 0, 9, 9])); await tick(); await tick(); - const events = await call(session, PEER_TRANSPORT_EVENTS, T.VersionedHostPeerTransportEventsRequest.enc({ tag: "V1" }), eventsCodec); + const events = await call(session, JAM_PEER_TRANSPORT_EVENTS, T.VersionedHostJamPeerTransportEventsRequest.enc({ tag: "V1" }), eventsCodec); expect(events).toEqual({ success: true, value: { tag: "V1", value: { events: [{ tag: "Accepted", value: { conn, stream: 1, kind: 0 } }] } } }); - const recv = await call(session, PEER_TRANSPORT_RECV, T.VersionedHostPeerTransportRecvRequest.enc({ tag: "V1", value: { stream: 1, max: 1 << 20 } }), recvCodec); + const recv = await call(session, JAM_PEER_TRANSPORT_RECV, T.VersionedHostJamPeerTransportRecvRequest.enc({ tag: "V1", value: { stream: 1, max: 1 << 20 } }), recvCodec); expect(recv).toEqual({ success: true, value: { tag: "V1", value: { message: "0x0909", fin: false, reset: false } } }); }); test("a peer close reports ConnClosed and invalidates streams; session close denies everything", async () => { const { session, transport, conn } = await dialed(); - const open = await call(session, PEER_TRANSPORT_OPEN, T.VersionedHostPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + const open = await call(session, JAM_PEER_TRANSPORT_OPEN, T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); if (!open.success) throw new Error("open failed"); transport.peerClose(); await tick(); await tick(); - const events = await call(session, PEER_TRANSPORT_EVENTS, T.VersionedHostPeerTransportEventsRequest.enc({ tag: "V1" }), eventsCodec); + const events = await call(session, JAM_PEER_TRANSPORT_EVENTS, T.VersionedHostJamPeerTransportEventsRequest.enc({ tag: "V1" }), eventsCodec); expect(events).toEqual({ success: true, value: { tag: "V1", value: { events: [{ tag: "ConnClosed", value: { conn } }] } } }); - const send = await call(session, PEER_TRANSPORT_SEND, T.VersionedHostPeerTransportSendRequest.enc({ tag: "V1", value: { stream: open.value.value.stream, message: "0x00", fin: false } }), sendCodec); + const send = await call(session, JAM_PEER_TRANSPORT_SEND, T.VersionedHostJamPeerTransportSendRequest.enc({ tag: "V1", value: { stream: open.value.value.stream, message: "0x00", fin: false } }), sendCodec); expect(send).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Closed" } } }); session.close(); - const response = decodeWireMessage(await session.handleFrame(frame(PEER_TRANSPORT_DIAL, dialRequest()))); + const response = decodeWireMessage(await session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, dialRequest()))); expect(response.isOk() && S.Result(S._void, S.CallError(S._void)).dec(response.value.payload.value)).toEqual({ success: false, value: { tag: "Denied" } }); }); }); diff --git a/js/packages/truapi/src/peer-transport.ts b/js/packages/truapi/src/jam-peer-transport.ts similarity index 80% rename from js/packages/truapi/src/peer-transport.ts rename to js/packages/truapi/src/jam-peer-transport.ts index fce715b098..c7e67dc325 100644 --- a/js/packages/truapi/src/peer-transport.ts +++ b/js/packages/truapi/src/jam-peer-transport.ts @@ -2,13 +2,13 @@ import * as S from "./scale.js"; import * as T from "./generated/types.js"; import { TRUAPI_CODEC_VERSION } from "./generated/client.js"; import { - PEER_TRANSPORT_CLOSE, - PEER_TRANSPORT_DIAL, - PEER_TRANSPORT_EVENTS, - PEER_TRANSPORT_OPEN, - PEER_TRANSPORT_RECV, - PEER_TRANSPORT_RESET, - PEER_TRANSPORT_SEND, + JAM_PEER_TRANSPORT_CLOSE, + JAM_PEER_TRANSPORT_DIAL, + JAM_PEER_TRANSPORT_EVENTS, + JAM_PEER_TRANSPORT_OPEN, + JAM_PEER_TRANSPORT_RECV, + JAM_PEER_TRANSPORT_RESET, + JAM_PEER_TRANSPORT_SEND, SYSTEM_HANDSHAKE, } from "./generated/wire-table.js"; import { @@ -20,33 +20,33 @@ import { type MethodIds, type ProtocolMessage, } from "./transport.js"; -import { webTransportCertificateHashes } from "./peer-transport-cert.js"; +import { webTransportCertificateHashes } from "./jam-peer-transport-cert.js"; -/** Caps mirrored from `truapi::v01::peer_transport`. */ -export const PEER_TRANSPORT_MAX_CONNECTIONS = 8; -export const PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION = 16; -export const PEER_TRANSPORT_MAX_MESSAGE_BYTES = 1 << 20; -export const PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION = 4 << 20; +/** Caps mirrored from `truapi::v01::jam_peer_transport`. */ +export const JAM_PEER_TRANSPORT_MAX_CONNECTIONS = 8; +export const JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION = 16; +export const JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES = 1 << 20; +export const JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION = 4 << 20; /** Largest request frame: a `send` of a maximal message plus SCALE and wire overhead. */ -export const PEER_TRANSPORT_MAX_FRAME_BYTES = PEER_TRANSPORT_MAX_MESSAGE_BYTES + 4096; +export const JAM_PEER_TRANSPORT_MAX_FRAME_BYTES = JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES + 4096; const MAX_PENDING_EVENTS = 1024; /** * Bound on one `dial`, from its arrival to its reply, the permission decision * included. A guest that waits at least this long for a dial reply never * misses one, and anything a dial would open after it is closed instead. */ -export const PEER_TRANSPORT_DIAL_TIMEOUT_MS = 10_000; +export const JAM_PEER_TRANSPORT_DIAL_TIMEOUT_MS = 10_000; const textEncoder = new TextEncoder(); const handshakeResult = S.Result(T.VersionedHostHandshakeResponse, S.CallError(T.VersionedHostHandshakeError)); const frameworkResult = S.Result(S._void, S.CallError(S._void)); -const dialResult = S.Result(T.VersionedHostPeerTransportDialResponse, S.CallError(T.VersionedHostPeerTransportDialError)); -const openResult = S.Result(T.VersionedHostPeerTransportOpenResponse, S.CallError(T.VersionedHostPeerTransportOpenError)); -const sendResult = S.Result(T.VersionedHostPeerTransportSendResponse, S.CallError(T.VersionedHostPeerTransportSendError)); -const recvResult = S.Result(T.VersionedHostPeerTransportRecvResponse, S.CallError(T.VersionedHostPeerTransportRecvError)); -const resetResult = S.Result(T.VersionedHostPeerTransportResetResponse, S.CallError(T.VersionedHostPeerTransportResetError)); -const closeResult = S.Result(T.VersionedHostPeerTransportCloseResponse, S.CallError(T.VersionedHostPeerTransportCloseError)); -const eventsResult = S.Result(T.VersionedHostPeerTransportEventsResponse, S.CallError(T.VersionedHostPeerTransportEventsError)); +const dialResult = S.Result(T.VersionedHostJamPeerTransportDialResponse, S.CallError(T.VersionedHostJamPeerTransportDialError)); +const openResult = S.Result(T.VersionedHostJamPeerTransportOpenResponse, S.CallError(T.VersionedHostJamPeerTransportOpenError)); +const sendResult = S.Result(T.VersionedHostJamPeerTransportSendResponse, S.CallError(T.VersionedHostJamPeerTransportSendError)); +const recvResult = S.Result(T.VersionedHostJamPeerTransportRecvResponse, S.CallError(T.VersionedHostJamPeerTransportRecvError)); +const resetResult = S.Result(T.VersionedHostJamPeerTransportResetResponse, S.CallError(T.VersionedHostJamPeerTransportResetError)); +const closeResult = S.Result(T.VersionedHostJamPeerTransportCloseResponse, S.CallError(T.VersionedHostJamPeerTransportCloseError)); +const eventsResult = S.Result(T.VersionedHostJamPeerTransportEventsResponse, S.CallError(T.VersionedHostJamPeerTransportEventsError)); const cancelledReply = frameworkResult.enc({ success: false, value: { tag: "Cancelled" } }); /** Minimal WebTransport surface the session needs; lets tests inject a fake. */ @@ -63,7 +63,7 @@ export interface WebTransportBidirectionalStreamLike { readonly writable: WritableStream; } -export interface PeerTransportOptions { +export interface JamPeerTransportOptions { /** * Decide whether this execution may dial peers of `genesis`, a `0x`-prefixed * lower-case 32-byte genesis header hash: the host's check of the @@ -76,12 +76,12 @@ export interface PeerTransportOptions { connect?: (url: string, certificateHashes: Uint8Array[]) => WebTransportLike; /** Unix seconds used to select certificate validity periods; defaults to the wall clock. */ now?: () => number; - /** Dial deadline in milliseconds; defaults to {@link PEER_TRANSPORT_DIAL_TIMEOUT_MS}. */ + /** Dial deadline in milliseconds; defaults to {@link JAM_PEER_TRANSPORT_DIAL_TIMEOUT_MS}. */ dialTimeoutMs?: number; } /** Execution-local peer endpoint. It provides no account or signing authority. */ -export interface PeerTransportSession { +export interface JamPeerTransportSession { /** Handle one request frame; CANCEL frames return zero bytes. */ handleFrame(frame: Uint8Array): Promise; /** Close every connection on stop or replacement and refuse further requests. */ @@ -104,7 +104,7 @@ function exact(codec: S.Codec, bytes: Uint8Array): V { } function decodeFrame(bytes: Uint8Array): ProtocolMessage { - if (!(bytes instanceof Uint8Array) || bytes.length > PEER_TRANSPORT_MAX_FRAME_BYTES) { + if (!(bytes instanceof Uint8Array) || bytes.length > JAM_PEER_TRANSPORT_MAX_FRAME_BYTES) { throw new Error("Invalid or oversized peer-transport frame"); } const decoded = decodeWireMessage(bytes); @@ -176,7 +176,7 @@ interface PeerConnection { } /** - * Create the browser PeerTransport endpoint for one execution. Every `dial` + * Create the browser JamPeerTransport endpoint for one execution. Every `dial` * is authorized for its genesis through `options.authorize` before anything * connects; the other methods act only on connections an authorized dial * opened. A dial answers within its deadline, prompt included: one still @@ -186,7 +186,7 @@ interface PeerConnection { * again. The host must fence late replies against execution stop or * replacement. */ -export function createPeerTransportSession(options: PeerTransportOptions): PeerTransportSession { +export function createJamPeerTransportSession(options: JamPeerTransportOptions): JamPeerTransportSession { const decisions = new Map>(); const authorized = (genesis: string): Promise => { let decision = decisions.get(genesis); @@ -209,7 +209,7 @@ export function createPeerTransportSession(options: PeerTransportOptions): PeerT serverCertificateHashes: hashes.map((value) => ({ algorithm: "sha-256", value: value as Uint8Array })), }) as unknown as WebTransportLike); const now = options.now ?? ((): number => Math.floor(Date.now() / 1000)); - const dialTimeoutMs = options.dialTimeoutMs ?? PEER_TRANSPORT_DIAL_TIMEOUT_MS; + const dialTimeoutMs = options.dialTimeoutMs ?? JAM_PEER_TRANSPORT_DIAL_TIMEOUT_MS; /** In-flight dials by request id; CANCEL or `close` withdraws one with its reply. */ const pendingDials = new Map void>(); let closed = false; @@ -218,9 +218,9 @@ export function createPeerTransportSession(options: PeerTransportOptions): PeerT let nextStream = 1; const connections = new Map(); const streams = new Map(); - const events: T.PeerTransportEvent[] = []; + const events: T.JamPeerTransportEvent[] = []; - const pushEvent = (event: T.PeerTransportEvent): void => { + const pushEvent = (event: T.JamPeerTransportEvent): void => { if (events.length < MAX_PENDING_EVENTS) events.push(event); }; @@ -251,7 +251,7 @@ export function createPeerTransportSession(options: PeerTransportOptions): PeerT while (stream.rx.length >= 4) { const view = new DataView(stream.rx.buffer, stream.rx.byteOffset, stream.rx.byteLength); const length = view.getUint32(0, true); - if (length > PEER_TRANSPORT_MAX_MESSAGE_BYTES) { + if (length > JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES) { stream.reset = true; void stream.writer.abort().catch(() => undefined); void stream.reader.cancel().catch(() => undefined); @@ -313,7 +313,7 @@ export function createPeerTransportSession(options: PeerTransportOptions): PeerT while (!conn.closed) { const { value: bidi, done } = await incoming.read(); if (done || conn.closed) break; - if (conn.streams.size >= PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION) { + if (conn.streams.size >= JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION) { void bidi.writable.abort().catch(() => undefined); void bidi.readable.cancel().catch(() => undefined); continue; @@ -339,12 +339,12 @@ export function createPeerTransportSession(options: PeerTransportOptions): PeerT * deadline passes; the guest then no longer waits for what this dial opens, * so nothing it opens outlives it or holds a connection slot. */ - const dial = async (request: T.HostPeerTransportDialRequest, withdrawn: Promise): Promise => { + const dial = async (request: T.HostJamPeerTransportDialRequest, withdrawn: Promise): Promise => { const granted = await Promise.race([authorized(request.genesis), withdrawn]); if (granted instanceof Uint8Array) return granted; if (!granted) return domain(dialResult, "NotGranted"); if (closed) return frameworkResult.enc({ success: false, value: { tag: "Denied" } }); - if (connections.size >= PEER_TRANSPORT_MAX_CONNECTIONS) return domain(dialResult, "Limit"); + if (connections.size >= JAM_PEER_TRANSPORT_MAX_CONNECTIONS) return domain(dialResult, "Limit"); // Browsers only expose WebTransport; JAMNP-S QUIC needs the P-256 identity. if (request.p256 === undefined) return domain(dialResult, "Unreachable"); const p256 = S.hexToBytes(request.p256); @@ -385,7 +385,7 @@ export function createPeerTransportSession(options: PeerTransportOptions): PeerT }; /** Run one dial frame: register it for CANCEL and arm its deadline. */ - const dialFrame = async (requestId: string, request: T.HostPeerTransportDialRequest): Promise => { + const dialFrame = async (requestId: string, request: T.HostJamPeerTransportDialRequest): Promise => { // Executor form: this package's lib target predates Promise.withResolvers. let withdraw!: (reply: Uint8Array) => void; const withdrawn = new Promise((resolve) => { @@ -401,10 +401,10 @@ export function createPeerTransportSession(options: PeerTransportOptions): PeerT } }; - const open = async (request: T.HostPeerTransportOpenRequest): Promise => { + const open = async (request: T.HostJamPeerTransportOpenRequest): Promise => { const conn = connections.get(request.conn); if (conn === undefined || conn.closed) return domain(openResult, "Closed"); - if (conn.streams.size >= PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION) return domain(openResult, "Limit"); + if (conn.streams.size >= JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION) return domain(openResult, "Limit"); try { const bidi = await conn.transport.createBidirectionalStream(); const stream = register(conn, bidi, new Uint8Array()); @@ -415,14 +415,14 @@ export function createPeerTransportSession(options: PeerTransportOptions): PeerT } }; - const send = async (request: T.HostPeerTransportSendRequest): Promise => { + const send = async (request: T.HostJamPeerTransportSendRequest): Promise => { const stream = streams.get(request.stream); if (stream === undefined || stream.txClosed || stream.reset || stream.conn.closed) return domain(sendResult, "Closed"); const message = S.hexToBytes(request.message); - if (message.length > PEER_TRANSPORT_MAX_MESSAGE_BYTES) return domain(sendResult, "TooLarge"); + if (message.length > JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES) return domain(sendResult, "TooLarge"); let pending = 0; for (const other of stream.conn.streams.values()) pending += other.txPending; - if (pending + message.length + 4 > PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION) return domain(sendResult, "Limit"); + if (pending + message.length + 4 > JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION) return domain(sendResult, "Limit"); const frame = new Uint8Array(4 + message.length); new DataView(frame.buffer).setUint32(0, message.length, true); frame.set(message, 4); @@ -442,7 +442,7 @@ export function createPeerTransportSession(options: PeerTransportOptions): PeerT } }; - const recv = (request: T.HostPeerTransportRecvRequest): Uint8Array => { + const recv = (request: T.HostJamPeerTransportRecvRequest): Uint8Array => { const stream = streams.get(request.stream); if (stream === undefined || stream.rxConsumed) return domain(recvResult, "Closed"); const next = stream.messages[0]; @@ -468,14 +468,14 @@ export function createPeerTransportSession(options: PeerTransportOptions): PeerT return ok(recvResult, { tag: "V1", value: { message, fin, reset } }); }; - const reset = (request: T.HostPeerTransportResetRequest): Uint8Array => { + const reset = (request: T.HostJamPeerTransportResetRequest): Uint8Array => { const stream = streams.get(request.stream); if (stream === undefined) return domain(resetResult, "Closed"); dropStream(stream, true); return ok(resetResult, { tag: "V1" }); }; - const close = (request: T.HostPeerTransportCloseRequest): Uint8Array => { + const close = (request: T.HostJamPeerTransportCloseRequest): Uint8Array => { const conn = connections.get(request.conn); if (conn === undefined || conn.closed) return domain(closeResult, "Closed"); dropConnection(conn); @@ -486,13 +486,13 @@ export function createPeerTransportSession(options: PeerTransportOptions): PeerT async handleFrame(bytes) { const request = decodeFrame(bytes); if (request.payload.messageType === MESSAGE_TYPE_CANCEL) { - if (request.payload.traitId !== PEER_TRANSPORT_DIAL.trait || request.payload.value.length !== 0) { + if (request.payload.traitId !== JAM_PEER_TRANSPORT_DIAL.trait || request.payload.value.length !== 0) { throw new Error("Invalid cancellation frame"); } // Only a dial can outlast one host tick: it may wait on a permission // prompt and a handshake. The dial itself answers `Cancelled`; a // CANCEL naming nothing in flight lost the race and is dropped. - if (hasIds(request, PEER_TRANSPORT_DIAL)) pendingDials.get(request.requestId)?.(cancelledReply); + if (hasIds(request, JAM_PEER_TRANSPORT_DIAL)) pendingDials.get(request.requestId)?.(cancelledReply); return new Uint8Array(); } if (request.payload.messageType !== MESSAGE_TYPE_REQUEST) { @@ -512,42 +512,42 @@ export function createPeerTransportSession(options: PeerTransportOptions): PeerT negotiated = true; return reply(request, handshakeResult.enc({ success: true, value: { tag: "V1" } })); } - if (request.payload.traitId !== PEER_TRANSPORT_DIAL.trait) { + if (request.payload.traitId !== JAM_PEER_TRANSPORT_DIAL.trait) { return reply(request, frameworkResult.enc({ success: false, value: { tag: "Denied" } })); } const malformed = (): Uint8Array => reply(request, frameworkResult.enc({ success: false, value: { tag: "MalformedFrame", value: { reason: "invalid peer-transport request" } } })); try { - if (hasIds(request, PEER_TRANSPORT_DIAL)) { - const value = exact(T.VersionedHostPeerTransportDialRequest, request.payload.value).value; + if (hasIds(request, JAM_PEER_TRANSPORT_DIAL)) { + const value = exact(T.VersionedHostJamPeerTransportDialRequest, request.payload.value).value; if (!negotiated) return reply(request, domain(dialResult, "NotGranted")); // Two live dials sharing an id leave neither addressable by CANCEL; // like the core dispatcher, the second is dropped unanswered. if (pendingDials.has(request.requestId)) return new Uint8Array(); return reply(request, await dialFrame(request.requestId, value)); } - if (hasIds(request, PEER_TRANSPORT_OPEN)) { - const value = exact(T.VersionedHostPeerTransportOpenRequest, request.payload.value).value; + if (hasIds(request, JAM_PEER_TRANSPORT_OPEN)) { + const value = exact(T.VersionedHostJamPeerTransportOpenRequest, request.payload.value).value; return reply(request, negotiated ? await open(value) : domain(openResult, "NotGranted")); } - if (hasIds(request, PEER_TRANSPORT_SEND)) { - const value = exact(T.VersionedHostPeerTransportSendRequest, request.payload.value).value; + if (hasIds(request, JAM_PEER_TRANSPORT_SEND)) { + const value = exact(T.VersionedHostJamPeerTransportSendRequest, request.payload.value).value; return reply(request, negotiated ? await send(value) : domain(sendResult, "Closed")); } - if (hasIds(request, PEER_TRANSPORT_RECV)) { - const value = exact(T.VersionedHostPeerTransportRecvRequest, request.payload.value).value; + if (hasIds(request, JAM_PEER_TRANSPORT_RECV)) { + const value = exact(T.VersionedHostJamPeerTransportRecvRequest, request.payload.value).value; return reply(request, negotiated ? recv(value) : domain(recvResult, "Closed")); } - if (hasIds(request, PEER_TRANSPORT_RESET)) { - const value = exact(T.VersionedHostPeerTransportResetRequest, request.payload.value).value; + if (hasIds(request, JAM_PEER_TRANSPORT_RESET)) { + const value = exact(T.VersionedHostJamPeerTransportResetRequest, request.payload.value).value; return reply(request, negotiated ? reset(value) : domain(resetResult, "Closed")); } - if (hasIds(request, PEER_TRANSPORT_CLOSE)) { - const value = exact(T.VersionedHostPeerTransportCloseRequest, request.payload.value).value; + if (hasIds(request, JAM_PEER_TRANSPORT_CLOSE)) { + const value = exact(T.VersionedHostJamPeerTransportCloseRequest, request.payload.value).value; return reply(request, negotiated ? close(value) : domain(closeResult, "Closed")); } - if (hasIds(request, PEER_TRANSPORT_EVENTS)) { - exact(T.VersionedHostPeerTransportEventsRequest, request.payload.value); + if (hasIds(request, JAM_PEER_TRANSPORT_EVENTS)) { + exact(T.VersionedHostJamPeerTransportEventsRequest, request.payload.value); if (!negotiated) return reply(request, domain(eventsResult, "NotGranted")); return reply(request, ok(eventsResult, { tag: "V1", value: { events: events.splice(0, events.length) } })); } diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index 12b3fb7f11..6780c6efab 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "eb7589907767e84c"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "11e5fe21ba1c8220"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; @@ -1087,6 +1087,195 @@ impl RequestMethod for EntropyDerive { const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; } +/// `jam_peer_transport_dial` method marker. +pub struct JamPeerTransportDial; +impl JamPeerTransportDial { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "JamPeerTransport", + method: "dial", + wire_name: "jam_peer_transport_dial", + request_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportDialRequest", + response_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportDialResponse", + error_type: Some("truapi::versioned::jam_peer_transport::HostJamPeerTransportDialError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 23, + method_id: 0, + }), + }; +} +impl RequestMethod for JamPeerTransportDial { + type Request = truapi::versioned::jam_peer_transport::HostJamPeerTransportDialRequest; + type Response = truapi::versioned::jam_peer_transport::HostJamPeerTransportDialResponse; + type Error = truapi::versioned::jam_peer_transport::HostJamPeerTransportDialError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `jam_peer_transport_open` method marker. +pub struct JamPeerTransportOpen; +impl JamPeerTransportOpen { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "JamPeerTransport", + method: "open", + wire_name: "jam_peer_transport_open", + request_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportOpenRequest", + response_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportOpenResponse", + error_type: Some("truapi::versioned::jam_peer_transport::HostJamPeerTransportOpenError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 23, + method_id: 1, + }), + }; +} +impl RequestMethod for JamPeerTransportOpen { + type Request = truapi::versioned::jam_peer_transport::HostJamPeerTransportOpenRequest; + type Response = truapi::versioned::jam_peer_transport::HostJamPeerTransportOpenResponse; + type Error = truapi::versioned::jam_peer_transport::HostJamPeerTransportOpenError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `jam_peer_transport_send` method marker. +pub struct JamPeerTransportSend; +impl JamPeerTransportSend { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "JamPeerTransport", + method: "send", + wire_name: "jam_peer_transport_send", + request_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportSendRequest", + response_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportSendResponse", + error_type: Some("truapi::versioned::jam_peer_transport::HostJamPeerTransportSendError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 23, + method_id: 2, + }), + }; +} +impl RequestMethod for JamPeerTransportSend { + type Request = truapi::versioned::jam_peer_transport::HostJamPeerTransportSendRequest; + type Response = truapi::versioned::jam_peer_transport::HostJamPeerTransportSendResponse; + type Error = truapi::versioned::jam_peer_transport::HostJamPeerTransportSendError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `jam_peer_transport_recv` method marker. +pub struct JamPeerTransportRecv; +impl JamPeerTransportRecv { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "JamPeerTransport", + method: "recv", + wire_name: "jam_peer_transport_recv", + request_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportRecvRequest", + response_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportRecvResponse", + error_type: Some("truapi::versioned::jam_peer_transport::HostJamPeerTransportRecvError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 23, + method_id: 3, + }), + }; +} +impl RequestMethod for JamPeerTransportRecv { + type Request = truapi::versioned::jam_peer_transport::HostJamPeerTransportRecvRequest; + type Response = truapi::versioned::jam_peer_transport::HostJamPeerTransportRecvResponse; + type Error = truapi::versioned::jam_peer_transport::HostJamPeerTransportRecvError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `jam_peer_transport_reset` method marker. +pub struct JamPeerTransportReset; +impl JamPeerTransportReset { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "JamPeerTransport", + method: "reset", + wire_name: "jam_peer_transport_reset", + request_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportResetRequest", + response_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportResetResponse", + error_type: Some("truapi::versioned::jam_peer_transport::HostJamPeerTransportResetError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 23, + method_id: 4, + }), + }; +} +impl RequestMethod for JamPeerTransportReset { + type Request = truapi::versioned::jam_peer_transport::HostJamPeerTransportResetRequest; + type Response = truapi::versioned::jam_peer_transport::HostJamPeerTransportResetResponse; + type Error = truapi::versioned::jam_peer_transport::HostJamPeerTransportResetError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `jam_peer_transport_close` method marker. +pub struct JamPeerTransportClose; +impl JamPeerTransportClose { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "JamPeerTransport", + method: "close", + wire_name: "jam_peer_transport_close", + request_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportCloseRequest", + response_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportCloseResponse", + error_type: Some("truapi::versioned::jam_peer_transport::HostJamPeerTransportCloseError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 23, + method_id: 5, + }), + }; +} +impl RequestMethod for JamPeerTransportClose { + type Request = truapi::versioned::jam_peer_transport::HostJamPeerTransportCloseRequest; + type Response = truapi::versioned::jam_peer_transport::HostJamPeerTransportCloseResponse; + type Error = truapi::versioned::jam_peer_transport::HostJamPeerTransportCloseError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `jam_peer_transport_events` method marker. +pub struct JamPeerTransportEvents; +impl JamPeerTransportEvents { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "JamPeerTransport", + method: "events", + wire_name: "jam_peer_transport_events", + request_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportEventsRequest", + response_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportEventsResponse", + error_type: Some("truapi::versioned::jam_peer_transport::HostJamPeerTransportEventsError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 23, + method_id: 6, + }), + }; +} +impl RequestMethod for JamPeerTransportEvents { + type Request = truapi::versioned::jam_peer_transport::HostJamPeerTransportEventsRequest; + type Response = truapi::versioned::jam_peer_transport::HostJamPeerTransportEventsResponse; + type Error = truapi::versioned::jam_peer_transport::HostJamPeerTransportEventsError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + /// `local_storage_read` method marker. pub struct LocalStorageRead; impl LocalStorageRead { @@ -1384,195 +1573,6 @@ impl RequestMethod for PaymentTopUp { const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; } -/// `peer_transport_dial` method marker. -pub struct PeerTransportDial; -impl PeerTransportDial { - /// Canonical metadata and frame ids for this method. - pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { - service: "PeerTransport", - method: "dial", - wire_name: "peer_transport_dial", - request_type: "truapi::versioned::peer_transport::HostPeerTransportDialRequest", - response_type: "truapi::versioned::peer_transport::HostPeerTransportDialResponse", - error_type: Some("truapi::versioned::peer_transport::HostPeerTransportDialError"), - kind: MethodKind::Request, - direction: Direction::ProductToHost, - required_execution: None, - wire: MethodWire::Request(MethodIds { - trait_id: 23, - method_id: 0, - }), - }; -} -impl RequestMethod for PeerTransportDial { - type Request = truapi::versioned::peer_transport::HostPeerTransportDialRequest; - type Response = truapi::versioned::peer_transport::HostPeerTransportDialResponse; - type Error = truapi::versioned::peer_transport::HostPeerTransportDialError; - const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; -} - -/// `peer_transport_open` method marker. -pub struct PeerTransportOpen; -impl PeerTransportOpen { - /// Canonical metadata and frame ids for this method. - pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { - service: "PeerTransport", - method: "open", - wire_name: "peer_transport_open", - request_type: "truapi::versioned::peer_transport::HostPeerTransportOpenRequest", - response_type: "truapi::versioned::peer_transport::HostPeerTransportOpenResponse", - error_type: Some("truapi::versioned::peer_transport::HostPeerTransportOpenError"), - kind: MethodKind::Request, - direction: Direction::ProductToHost, - required_execution: None, - wire: MethodWire::Request(MethodIds { - trait_id: 23, - method_id: 1, - }), - }; -} -impl RequestMethod for PeerTransportOpen { - type Request = truapi::versioned::peer_transport::HostPeerTransportOpenRequest; - type Response = truapi::versioned::peer_transport::HostPeerTransportOpenResponse; - type Error = truapi::versioned::peer_transport::HostPeerTransportOpenError; - const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; -} - -/// `peer_transport_send` method marker. -pub struct PeerTransportSend; -impl PeerTransportSend { - /// Canonical metadata and frame ids for this method. - pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { - service: "PeerTransport", - method: "send", - wire_name: "peer_transport_send", - request_type: "truapi::versioned::peer_transport::HostPeerTransportSendRequest", - response_type: "truapi::versioned::peer_transport::HostPeerTransportSendResponse", - error_type: Some("truapi::versioned::peer_transport::HostPeerTransportSendError"), - kind: MethodKind::Request, - direction: Direction::ProductToHost, - required_execution: None, - wire: MethodWire::Request(MethodIds { - trait_id: 23, - method_id: 2, - }), - }; -} -impl RequestMethod for PeerTransportSend { - type Request = truapi::versioned::peer_transport::HostPeerTransportSendRequest; - type Response = truapi::versioned::peer_transport::HostPeerTransportSendResponse; - type Error = truapi::versioned::peer_transport::HostPeerTransportSendError; - const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; -} - -/// `peer_transport_recv` method marker. -pub struct PeerTransportRecv; -impl PeerTransportRecv { - /// Canonical metadata and frame ids for this method. - pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { - service: "PeerTransport", - method: "recv", - wire_name: "peer_transport_recv", - request_type: "truapi::versioned::peer_transport::HostPeerTransportRecvRequest", - response_type: "truapi::versioned::peer_transport::HostPeerTransportRecvResponse", - error_type: Some("truapi::versioned::peer_transport::HostPeerTransportRecvError"), - kind: MethodKind::Request, - direction: Direction::ProductToHost, - required_execution: None, - wire: MethodWire::Request(MethodIds { - trait_id: 23, - method_id: 3, - }), - }; -} -impl RequestMethod for PeerTransportRecv { - type Request = truapi::versioned::peer_transport::HostPeerTransportRecvRequest; - type Response = truapi::versioned::peer_transport::HostPeerTransportRecvResponse; - type Error = truapi::versioned::peer_transport::HostPeerTransportRecvError; - const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; -} - -/// `peer_transport_reset` method marker. -pub struct PeerTransportReset; -impl PeerTransportReset { - /// Canonical metadata and frame ids for this method. - pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { - service: "PeerTransport", - method: "reset", - wire_name: "peer_transport_reset", - request_type: "truapi::versioned::peer_transport::HostPeerTransportResetRequest", - response_type: "truapi::versioned::peer_transport::HostPeerTransportResetResponse", - error_type: Some("truapi::versioned::peer_transport::HostPeerTransportResetError"), - kind: MethodKind::Request, - direction: Direction::ProductToHost, - required_execution: None, - wire: MethodWire::Request(MethodIds { - trait_id: 23, - method_id: 4, - }), - }; -} -impl RequestMethod for PeerTransportReset { - type Request = truapi::versioned::peer_transport::HostPeerTransportResetRequest; - type Response = truapi::versioned::peer_transport::HostPeerTransportResetResponse; - type Error = truapi::versioned::peer_transport::HostPeerTransportResetError; - const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; -} - -/// `peer_transport_close` method marker. -pub struct PeerTransportClose; -impl PeerTransportClose { - /// Canonical metadata and frame ids for this method. - pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { - service: "PeerTransport", - method: "close", - wire_name: "peer_transport_close", - request_type: "truapi::versioned::peer_transport::HostPeerTransportCloseRequest", - response_type: "truapi::versioned::peer_transport::HostPeerTransportCloseResponse", - error_type: Some("truapi::versioned::peer_transport::HostPeerTransportCloseError"), - kind: MethodKind::Request, - direction: Direction::ProductToHost, - required_execution: None, - wire: MethodWire::Request(MethodIds { - trait_id: 23, - method_id: 5, - }), - }; -} -impl RequestMethod for PeerTransportClose { - type Request = truapi::versioned::peer_transport::HostPeerTransportCloseRequest; - type Response = truapi::versioned::peer_transport::HostPeerTransportCloseResponse; - type Error = truapi::versioned::peer_transport::HostPeerTransportCloseError; - const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; -} - -/// `peer_transport_events` method marker. -pub struct PeerTransportEvents; -impl PeerTransportEvents { - /// Canonical metadata and frame ids for this method. - pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { - service: "PeerTransport", - method: "events", - wire_name: "peer_transport_events", - request_type: "truapi::versioned::peer_transport::HostPeerTransportEventsRequest", - response_type: "truapi::versioned::peer_transport::HostPeerTransportEventsResponse", - error_type: Some("truapi::versioned::peer_transport::HostPeerTransportEventsError"), - kind: MethodKind::Request, - direction: Direction::ProductToHost, - required_execution: None, - wire: MethodWire::Request(MethodIds { - trait_id: 23, - method_id: 6, - }), - }; -} -impl RequestMethod for PeerTransportEvents { - type Request = truapi::versioned::peer_transport::HostPeerTransportEventsRequest; - type Response = truapi::versioned::peer_transport::HostPeerTransportEventsResponse; - type Error = truapi::versioned::peer_transport::HostPeerTransportEventsError; - const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; -} - /// `permissions_request_device_permission` method marker. pub struct PermissionsRequestDevicePermission; impl PermissionsRequestDevicePermission { @@ -2455,6 +2455,13 @@ pub const APP_METHODS: &[MethodDescriptor] = &[ CoinPaymentRefund::DESCRIPTOR, CoinPaymentListenForPayment::DESCRIPTOR, EntropyDerive::DESCRIPTOR, + JamPeerTransportDial::DESCRIPTOR, + JamPeerTransportOpen::DESCRIPTOR, + JamPeerTransportSend::DESCRIPTOR, + JamPeerTransportRecv::DESCRIPTOR, + JamPeerTransportReset::DESCRIPTOR, + JamPeerTransportClose::DESCRIPTOR, + JamPeerTransportEvents::DESCRIPTOR, LocalStorageRead::DESCRIPTOR, LocalStorageWrite::DESCRIPTOR, LocalStorageClear::DESCRIPTOR, @@ -2466,13 +2473,6 @@ pub const APP_METHODS: &[MethodDescriptor] = &[ PaymentRequest::DESCRIPTOR, PaymentStatusSubscribe::DESCRIPTOR, PaymentTopUp::DESCRIPTOR, - PeerTransportDial::DESCRIPTOR, - PeerTransportOpen::DESCRIPTOR, - PeerTransportSend::DESCRIPTOR, - PeerTransportRecv::DESCRIPTOR, - PeerTransportReset::DESCRIPTOR, - PeerTransportClose::DESCRIPTOR, - PeerTransportEvents::DESCRIPTOR, PermissionsRequestDevicePermission::DESCRIPTOR, PermissionsRequestRemotePermission::DESCRIPTOR, PermissionsAuthorizeRemotePermission::DESCRIPTOR, @@ -2537,6 +2537,13 @@ pub const WIDGET_METHODS: &[MethodDescriptor] = &[ CoinPaymentRefund::DESCRIPTOR, CoinPaymentListenForPayment::DESCRIPTOR, EntropyDerive::DESCRIPTOR, + JamPeerTransportDial::DESCRIPTOR, + JamPeerTransportOpen::DESCRIPTOR, + JamPeerTransportSend::DESCRIPTOR, + JamPeerTransportRecv::DESCRIPTOR, + JamPeerTransportReset::DESCRIPTOR, + JamPeerTransportClose::DESCRIPTOR, + JamPeerTransportEvents::DESCRIPTOR, LocalStorageRead::DESCRIPTOR, LocalStorageWrite::DESCRIPTOR, LocalStorageClear::DESCRIPTOR, @@ -2548,13 +2555,6 @@ pub const WIDGET_METHODS: &[MethodDescriptor] = &[ PaymentRequest::DESCRIPTOR, PaymentStatusSubscribe::DESCRIPTOR, PaymentTopUp::DESCRIPTOR, - PeerTransportDial::DESCRIPTOR, - PeerTransportOpen::DESCRIPTOR, - PeerTransportSend::DESCRIPTOR, - PeerTransportRecv::DESCRIPTOR, - PeerTransportReset::DESCRIPTOR, - PeerTransportClose::DESCRIPTOR, - PeerTransportEvents::DESCRIPTOR, PermissionsRequestDevicePermission::DESCRIPTOR, PermissionsRequestRemotePermission::DESCRIPTOR, PermissionsAuthorizeRemotePermission::DESCRIPTOR, @@ -2624,6 +2624,13 @@ pub const WORKER_METHODS: &[MethodDescriptor] = &[ CoinPaymentRefund::DESCRIPTOR, CoinPaymentListenForPayment::DESCRIPTOR, EntropyDerive::DESCRIPTOR, + JamPeerTransportDial::DESCRIPTOR, + JamPeerTransportOpen::DESCRIPTOR, + JamPeerTransportSend::DESCRIPTOR, + JamPeerTransportRecv::DESCRIPTOR, + JamPeerTransportReset::DESCRIPTOR, + JamPeerTransportClose::DESCRIPTOR, + JamPeerTransportEvents::DESCRIPTOR, LocalStorageRead::DESCRIPTOR, LocalStorageWrite::DESCRIPTOR, LocalStorageClear::DESCRIPTOR, @@ -2635,13 +2642,6 @@ pub const WORKER_METHODS: &[MethodDescriptor] = &[ PaymentRequest::DESCRIPTOR, PaymentStatusSubscribe::DESCRIPTOR, PaymentTopUp::DESCRIPTOR, - PeerTransportDial::DESCRIPTOR, - PeerTransportOpen::DESCRIPTOR, - PeerTransportSend::DESCRIPTOR, - PeerTransportRecv::DESCRIPTOR, - PeerTransportReset::DESCRIPTOR, - PeerTransportClose::DESCRIPTOR, - PeerTransportEvents::DESCRIPTOR, PermissionsRequestDevicePermission::DESCRIPTOR, PermissionsRequestRemotePermission::DESCRIPTOR, PermissionsAuthorizeRemotePermission::DESCRIPTOR, diff --git a/rust/crates/truapi-codegen/src/rust.rs b/rust/crates/truapi-codegen/src/rust.rs index 5380815522..1e2bf63ca1 100644 --- a/rust/crates/truapi-codegen/src/rust.rs +++ b/rust/crates/truapi-codegen/src/rust.rs @@ -62,10 +62,10 @@ const TRAIT_MODULE_MAP: &[(&str, &str)] = &[ ("Chain", "chain"), ("Chat", "chat"), ("Entropy", "entropy"), + ("JamPeerTransport", "jam_peer_transport"), ("JsonRpc", "jsonrpc"), ("LocalStorage", "local_storage"), ("Payment", "payment"), - ("PeerTransport", "peer_transport"), ("Permissions", "permissions"), ("Preimage", "preimage"), ("Renderer", "renderer"), diff --git a/rust/crates/truapi-server/src/core.rs b/rust/crates/truapi-server/src/core.rs index f8845c4ce9..59a18a8990 100644 --- a/rust/crates/truapi-server/src/core.rs +++ b/rust/crates/truapi-server/src/core.rs @@ -188,7 +188,7 @@ mod tests { use crate::test_support::{StubPlatform, runtime_config, test_spawner}; #[test] - fn a_published_product_has_no_implicit_peer_transport_grant() { + fn a_published_product_has_no_implicit_jam_peer_transport_grant() { let (host_config, product) = runtime_config("dotli.dot"); let core = TrUApiCore::from_platform_with_config( Arc::new(StubPlatform::default()), @@ -196,15 +196,15 @@ mod tests { product, test_spawner(), ); - let ids = request_ids("peer_transport_dial").expect("registered peer transport"); + let ids = request_ids("jam_peer_transport_dial").expect("registered peer transport"); let frame = ProtocolMessage { request_id: "p:peer".into(), payload: Payload { trait_id: ids.trait_id, method_id: ids.method_id, message_type: crate::frame::MESSAGE_TYPE_REQUEST, - value: truapi::versioned::peer_transport::HostPeerTransportDialRequest::V1( - truapi::latest::HostPeerTransportDialRequest { + value: truapi::versioned::jam_peer_transport::HostJamPeerTransportDialRequest::V1( + truapi::latest::HostJamPeerTransportDialRequest { genesis: [0x35; 32], ip: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1], port: 43000, @@ -222,10 +222,10 @@ mod tests { .unwrap() .payload .value, - Err::( + Err::( truapi::CallError::Domain( - truapi::versioned::peer_transport::HostPeerTransportDialError::V1( - truapi::latest::HostPeerTransportDialError::NotGranted, + truapi::versioned::jam_peer_transport::HostJamPeerTransportDialError::V1( + truapi::latest::HostJamPeerTransportDialError::NotGranted, ), ), ) diff --git a/rust/crates/truapi-server/src/peer_transport.rs b/rust/crates/truapi-server/src/jam_peer_transport.rs similarity index 90% rename from rust/crates/truapi-server/src/peer_transport.rs rename to rust/crates/truapi-server/src/jam_peer_transport.rs index fdd9baf531..42db00cdd6 100644 --- a/rust/crates/truapi-server/src/peer_transport.rs +++ b/rust/crates/truapi-server/src/jam_peer_transport.rs @@ -1,4 +1,4 @@ -//! JAMNP-S helpers for hosts that implement `PeerTransport`. +//! JAMNP-S helpers for hosts that implement `JamPeerTransport`. //! //! Peer access is a runtime permission, not a manifest capability: before a //! `dial` connects, the host requires @@ -6,11 +6,11 @@ //! for the requested genesis, reading the product's stored decision, prompting //! when it is undetermined and persisting the answer per product and genesis. //! Anything short of a grant answers -//! [`NotGranted`](truapi::latest::HostPeerTransportDialError::NotGranted). +//! [`NotGranted`](truapi::latest::HostJamPeerTransportDialError::NotGranted). //! //! The host also owns the transport: it builds the JAMNP-S ALPN from the //! genesis ([`alpn`]), verifies the peer certificate against the identity the -//! guest named, frames messages and enforces the `PEER_TRANSPORT_MAX_*` caps +//! guest named, frames messages and enforces the `JAM_PEER_TRANSPORT_MAX_*` caps //! from `truapi::latest`. use core::fmt; diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index 536021e1da..4f89d071d7 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -31,8 +31,8 @@ pub mod frame; pub(crate) mod host_core; pub mod host_logic; pub(crate) mod host_rpc_client; +pub mod jam_peer_transport; pub mod logging; -pub mod peer_transport; pub(crate) mod runtime; pub mod subscription; pub mod transport; diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index fca788fe71..50007acc82 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -81,7 +81,7 @@ use truapi::versioned::chat::{ HostChatPostMessageError, HostChatPostMessageRequest, HostChatPostMessageResponse, HostChatRegisterBotError, HostChatRegisterBotRequest, HostChatRegisterBotResponse, }; -use truapi::versioned::peer_transport::HostPeerTransportDialError; +use truapi::versioned::jam_peer_transport::HostJamPeerTransportDialError; use truapi::versioned::pocket::{ HostPocketListSubscribeError, HostPocketListSubscribeItem, HostPocketListSubscribeRequest, HostPocketRemoveCardError, HostPocketRemoveCardRequest, HostPocketRemoveCardResponse, @@ -710,7 +710,7 @@ impl ProductRuntimeHost { .await } - /// Gate `PeerTransport::dial` on + /// Gate `JamPeerTransport::dial` on /// [`RemotePermission::JamPeers`](v01::RemotePermission::JamPeers) for /// `genesis`, before anything connects. /// @@ -725,14 +725,14 @@ impl ProductRuntimeHost { not(test), expect( dead_code, - reason = "the core has no native PeerTransport yet; its dial must call this first" + reason = "the core has no native JamPeerTransport yet; its dial must call this first" ) )] - #[instrument(skip_all, fields(runtime.method = "peer_transport.require_jam_peers"))] + #[instrument(skip_all, fields(runtime.method = "jam_peer_transport.require_jam_peers"))] pub(crate) async fn require_jam_peers( &self, genesis: [u8; 32], - ) -> Result<(), CallError> { + ) -> Result<(), CallError> { let request = v01::RemotePermissionRequest { permission: v01::RemotePermission::JamPeers { genesis }, }; @@ -745,8 +745,8 @@ impl ProductRuntimeHost { Ok( PermissionAuthorizationStatus::Denied | PermissionAuthorizationStatus::NotDetermined, - ) => Err(CallError::Domain(HostPeerTransportDialError::V1( - v01::HostPeerTransportDialError::NotGranted, + ) => Err(CallError::Domain(HostJamPeerTransportDialError::V1( + v01::HostJamPeerTransportDialError::NotGranted, ))), Err(err) => Err(CallError::HostFailure { reason: format!("permission storage failed: {err:?}"), diff --git a/rust/crates/truapi-server/src/runtime/capabilities/resources.rs b/rust/crates/truapi-server/src/runtime/capabilities/resources.rs index 9b8975e0df..9014641d16 100644 --- a/rust/crates/truapi-server/src/runtime/capabilities/resources.rs +++ b/rust/crates/truapi-server/src/runtime/capabilities/resources.rs @@ -19,7 +19,7 @@ use crate::runtime::{ // Published product runtimes have no JAM peer-transport grant. #[truapi::async_trait] -impl truapi::api::PeerTransport for ProductRuntimeHost {} +impl truapi::api::JamPeerTransport for ProductRuntimeHost {} #[truapi::async_trait] impl ResourceAllocation for ProductRuntimeHost { diff --git a/rust/crates/truapi-server/src/runtime/tests.rs b/rust/crates/truapi-server/src/runtime/tests.rs index 15fd74edc8..cc67122d71 100644 --- a/rust/crates/truapi-server/src/runtime/tests.rs +++ b/rust/crates/truapi-server/src/runtime/tests.rs @@ -1356,9 +1356,9 @@ fn jam_peers(genesis: [u8; 32]) -> v01::RemotePermissionRequest { } } -fn jam_peers_not_granted() -> CallError { - CallError::Domain(HostPeerTransportDialError::V1( - v01::HostPeerTransportDialError::NotGranted, +fn jam_peers_not_granted() -> CallError { + CallError::Domain(HostJamPeerTransportDialError::V1( + v01::HostJamPeerTransportDialError::NotGranted, )) } diff --git a/rust/crates/truapi-server/tests/peer_transport_contract.rs b/rust/crates/truapi-server/tests/jam_peer_transport_contract.rs similarity index 63% rename from rust/crates/truapi-server/tests/peer_transport_contract.rs rename to rust/crates/truapi-server/tests/jam_peer_transport_contract.rs index 04fa49642d..552ac827c6 100644 --- a/rust/crates/truapi-server/tests/peer_transport_contract.rs +++ b/rust/crates/truapi-server/tests/jam_peer_transport_contract.rs @@ -1,4 +1,4 @@ -//! PeerTransport contract regression test. +//! JamPeerTransport contract regression test. //! //! Pins the frozen trait-23 wire ids and SCALE layouts, the `JamPeers` //! permission's place in `RemotePermission`, and the genesis/ALPN helpers a @@ -6,12 +6,13 @@ use parity_scale_codec::{Decode, Encode}; use truapi::latest; -use truapi::versioned::peer_transport; +use truapi::versioned::jam_peer_transport; use truapi_server::generated::wire_table::{ - MethodIds, PEER_TRANSPORT_CLOSE, PEER_TRANSPORT_DIAL, PEER_TRANSPORT_EVENTS, - PEER_TRANSPORT_OPEN, PEER_TRANSPORT_RECV, PEER_TRANSPORT_RESET, PEER_TRANSPORT_SEND, + JAM_PEER_TRANSPORT_CLOSE, JAM_PEER_TRANSPORT_DIAL, JAM_PEER_TRANSPORT_EVENTS, + JAM_PEER_TRANSPORT_OPEN, JAM_PEER_TRANSPORT_RECV, JAM_PEER_TRANSPORT_RESET, + JAM_PEER_TRANSPORT_SEND, MethodIds, }; -use truapi_server::peer_transport::{InvalidGenesis, alpn, parse_genesis}; +use truapi_server::jam_peer_transport::{InvalidGenesis, alpn, parse_genesis}; const GENESIS_HEX: &str = "353963b9cedfe4ea22038081052a5c151b06b55a4a026a97522cd0320cabf49f"; @@ -70,13 +71,13 @@ fn jam_peers_is_the_last_remote_permission_and_names_its_genesis() { #[test] fn the_wire_ids_and_scale_layout_match_the_frozen_contract() { for (ids, method_id) in [ - (PEER_TRANSPORT_DIAL, 0), - (PEER_TRANSPORT_OPEN, 1), - (PEER_TRANSPORT_SEND, 2), - (PEER_TRANSPORT_RECV, 3), - (PEER_TRANSPORT_RESET, 4), - (PEER_TRANSPORT_CLOSE, 5), - (PEER_TRANSPORT_EVENTS, 6), + (JAM_PEER_TRANSPORT_DIAL, 0), + (JAM_PEER_TRANSPORT_OPEN, 1), + (JAM_PEER_TRANSPORT_SEND, 2), + (JAM_PEER_TRANSPORT_RECV, 3), + (JAM_PEER_TRANSPORT_RESET, 4), + (JAM_PEER_TRANSPORT_CLOSE, 5), + (JAM_PEER_TRANSPORT_EVENTS, 6), ] { assert_eq!( ids, @@ -87,15 +88,16 @@ fn the_wire_ids_and_scale_layout_match_the_frozen_contract() { ); } - let dial = - peer_transport::HostPeerTransportDialRequest::V1(latest::HostPeerTransportDialRequest { + let dial = jam_peer_transport::HostJamPeerTransportDialRequest::V1( + latest::HostJamPeerTransportDialRequest { genesis: genesis(), ip: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1], port: 43000, ed25519: [0x11; 32], p256: Some([0x02; 33]), - }) - .encode(); + }, + ) + .encode(); let mut expected = vec![0u8]; expected.extend_from_slice(&genesis()); expected.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1]); @@ -106,30 +108,34 @@ fn the_wire_ids_and_scale_layout_match_the_frozen_contract() { assert_eq!(dial, expected); assert_eq!( - peer_transport::HostPeerTransportSendRequest::V1(latest::HostPeerTransportSendRequest { - stream: 7, - message: vec![0xaa, 0xbb], - fin: true, - }) + jam_peer_transport::HostJamPeerTransportSendRequest::V1( + latest::HostJamPeerTransportSendRequest { + stream: 7, + message: vec![0xaa, 0xbb], + fin: true, + } + ) .encode(), vec![0, 7, 0, 0, 0, 8, 0xaa, 0xbb, 1] ); assert_eq!( - peer_transport::HostPeerTransportRecvResponse::V1(latest::HostPeerTransportRecvResponse { - message: None, - fin: false, - reset: true, - }) + jam_peer_transport::HostJamPeerTransportRecvResponse::V1( + latest::HostJamPeerTransportRecvResponse { + message: None, + fin: false, + reset: true, + } + ) .encode(), vec![0, 0, 0, 1] ); assert_eq!( - peer_transport::HostPeerTransportEventsResponse::V1( - latest::HostPeerTransportEventsResponse { + jam_peer_transport::HostJamPeerTransportEventsResponse::V1( + latest::HostJamPeerTransportEventsResponse { events: vec![ - latest::PeerTransportEvent::ConnClosed { conn: 1 }, - latest::PeerTransportEvent::StreamFin { stream: 2 }, - latest::PeerTransportEvent::Accepted { + latest::JamPeerTransportEvent::ConnClosed { conn: 1 }, + latest::JamPeerTransportEvent::StreamFin { stream: 2 }, + latest::JamPeerTransportEvent::Accepted { conn: 1, stream: 3, kind: 0, @@ -143,21 +149,21 @@ fn the_wire_ids_and_scale_layout_match_the_frozen_contract() { ] ); assert_eq!( - peer_transport::HostPeerTransportDialError::V1( - latest::HostPeerTransportDialError::Unreachable + jam_peer_transport::HostJamPeerTransportDialError::V1( + latest::HostJamPeerTransportDialError::Unreachable ) .encode(), vec![0, 3] ); assert_eq!( - peer_transport::HostPeerTransportEventsRequest::V1.encode(), + jam_peer_transport::HostJamPeerTransportEventsRequest::V1.encode(), vec![0] ); - assert_eq!(latest::PEER_TRANSPORT_MAX_CONNECTIONS, 8); - assert_eq!(latest::PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION, 16); - assert_eq!(latest::PEER_TRANSPORT_MAX_MESSAGE_BYTES, 1 << 20); + assert_eq!(latest::JAM_PEER_TRANSPORT_MAX_CONNECTIONS, 8); + assert_eq!(latest::JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION, 16); + assert_eq!(latest::JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES, 1 << 20); assert_eq!( - latest::PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION, + latest::JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION, 4 << 20 ); } diff --git a/rust/crates/truapi/src/api.rs b/rust/crates/truapi/src/api.rs index 563283740b..9d4b4b4016 100644 --- a/rust/crates/truapi/src/api.rs +++ b/rust/crates/truapi/src/api.rs @@ -5,11 +5,11 @@ pub mod chain; pub mod chat; pub mod coin_payment; pub mod entropy; +pub mod jam_peer_transport; pub mod local_storage; pub mod locale; pub mod notifications; pub mod payment; -pub mod peer_transport; pub mod permissions; pub mod pocket; pub mod preimage; @@ -26,11 +26,11 @@ pub use chain::Chain; pub use chat::Chat; pub use coin_payment::CoinPayment; pub use entropy::Entropy; +pub use jam_peer_transport::JamPeerTransport; pub use local_storage::LocalStorage; pub use locale::Locale; pub use notifications::Notifications; pub use payment::Payment; -pub use peer_transport::PeerTransport; pub use permissions::Permissions; pub use pocket::Pocket; pub use preimage::Preimage; @@ -49,11 +49,11 @@ pub trait TrUApi: + Chat + CoinPayment + Entropy + + JamPeerTransport + LocalStorage + Locale + Notifications + Payment - + PeerTransport + Permissions + Pocket + Preimage @@ -75,11 +75,11 @@ impl TrUApi for T where + Chat + CoinPayment + Entropy + + JamPeerTransport + LocalStorage + Locale + Notifications + Payment - + PeerTransport + Permissions + Pocket + Preimage diff --git a/rust/crates/truapi/src/api/jam_peer_transport.rs b/rust/crates/truapi/src/api/jam_peer_transport.rs new file mode 100644 index 0000000000..5ca2aa519c --- /dev/null +++ b/rust/crates/truapi/src/api/jam_peer_transport.rs @@ -0,0 +1,158 @@ +//! Unified [`JamPeerTransport`] trait. + +use crate::versioned::jam_peer_transport::{ + HostJamPeerTransportCloseError, HostJamPeerTransportCloseRequest, + HostJamPeerTransportCloseResponse, HostJamPeerTransportDialError, + HostJamPeerTransportDialRequest, HostJamPeerTransportDialResponse, + HostJamPeerTransportEventsError, HostJamPeerTransportEventsRequest, + HostJamPeerTransportEventsResponse, HostJamPeerTransportOpenError, + HostJamPeerTransportOpenRequest, HostJamPeerTransportOpenResponse, + HostJamPeerTransportRecvError, HostJamPeerTransportRecvRequest, + HostJamPeerTransportRecvResponse, HostJamPeerTransportResetError, + HostJamPeerTransportResetRequest, HostJamPeerTransportResetResponse, + HostJamPeerTransportSendError, HostJamPeerTransportSendRequest, + HostJamPeerTransportSendResponse, +}; +use crate::{CallContext, CallError, v01, wire, wire_trait}; + +/// Host-terminated QUIC/WebTransport streams to JAM peers (JAMNP-S). +/// +/// The host owns TLS, certificate verification and length framing; the guest +/// verifies every byte it consumes. Access is a runtime permission, not a +/// manifest declaration: `dial` requires +/// [`RemotePermission::JamPeers`](crate::v01::RemotePermission::JamPeers) for +/// its `genesis`, checking the product's stored decision, prompting when it is +/// undetermined and persisting the answer per product and genesis. The other +/// methods act only on connections a granted `dial` opened. A grant is +/// separate from account, signing and storage authority. +#[wire_trait(id = 23)] +#[crate::async_trait] +pub trait JamPeerTransport: Send + Sync { + /// Dial one peer. The host builds the ALPN from `genesis` and requires the + /// peer certificate to carry `ed25519` (QUIC) or to hash to the + /// certificate derived from `p256` (WebTransport). + /// + /// ```ts + /// const result = await truapi.jamPeerTransport.dial({ + /// genesis: "0x353963b9cedfe4ea22038081052a5c151b06b55a4a026a97522cd0320cabf49f", + /// ip: "0x00000000000000000000ffff7f000001", + /// port: 43000, + /// ed25519: "0x0000000000000000000000000000000000000000000000000000000000000000", + /// p256: undefined, + /// }); + /// if (result.isOk()) console.log("connection:", result.value.conn); + /// ``` + #[wire(id = 0)] + async fn dial( + &self, + _cx: &CallContext, + _request: HostJamPeerTransportDialRequest, + ) -> Result> { + Err(CallError::Domain(HostJamPeerTransportDialError::V1( + v01::HostJamPeerTransportDialError::NotGranted, + ))) + } + + /// Open a bidirectional stream on a connection and send its kind byte. + /// + /// ```ts + /// const result = await truapi.jamPeerTransport.open({ conn: 0, kind: 0 }); + /// if (result.isOk()) console.log("stream:", result.value.stream); + /// ``` + #[wire(id = 1)] + async fn open( + &self, + _cx: &CallContext, + _request: HostJamPeerTransportOpenRequest, + ) -> Result> { + Err(CallError::Domain(HostJamPeerTransportOpenError::V1( + v01::HostJamPeerTransportOpenError::NotGranted, + ))) + } + + /// Queue one message; the host prepends the `u32` little-endian length. + /// + /// ```ts + /// const result = await truapi.jamPeerTransport.send({ stream: 0, message: "0x00", fin: false }); + /// console.log("sent:", result.isOk()); + /// ``` + #[wire(id = 2)] + async fn send( + &self, + _cx: &CallContext, + _request: HostJamPeerTransportSendRequest, + ) -> Result> { + Err(CallError::Domain(HostJamPeerTransportSendError::V1( + v01::HostJamPeerTransportSendError::Closed, + ))) + } + + /// Poll one complete message without blocking; the host strips the length. + /// + /// ```ts + /// const result = await truapi.jamPeerTransport.recv({ stream: 0, max: 1048576 }); + /// if (result.isOk()) console.log("message:", result.value.message, "fin:", result.value.fin); + /// ``` + #[wire(id = 3)] + async fn recv( + &self, + _cx: &CallContext, + _request: HostJamPeerTransportRecvRequest, + ) -> Result> { + Err(CallError::Domain(HostJamPeerTransportRecvError::V1( + v01::HostJamPeerTransportRecvError::Closed, + ))) + } + + /// Abort a stream in both directions. + /// + /// ```ts + /// const result = await truapi.jamPeerTransport.reset({ stream: 0 }); + /// console.log("reset:", result.isOk()); + /// ``` + #[wire(id = 4)] + async fn reset( + &self, + _cx: &CallContext, + _request: HostJamPeerTransportResetRequest, + ) -> Result> { + Err(CallError::Domain(HostJamPeerTransportResetError::V1( + v01::HostJamPeerTransportResetError::Closed, + ))) + } + + /// Close a connection and every stream on it. + /// + /// ```ts + /// const result = await truapi.jamPeerTransport.close({ conn: 0 }); + /// console.log("closed:", result.isOk()); + /// ``` + #[wire(id = 5)] + async fn close( + &self, + _cx: &CallContext, + _request: HostJamPeerTransportCloseRequest, + ) -> Result> { + Err(CallError::Domain(HostJamPeerTransportCloseError::V1( + v01::HostJamPeerTransportCloseError::Closed, + ))) + } + + /// Drain connection, stream-finish and inbound-stream events. + /// + /// ```ts + /// const result = await truapi.jamPeerTransport.events(); + /// if (result.isOk()) console.log("events:", result.value.events); + /// ``` + #[wire(id = 6)] + async fn events( + &self, + _cx: &CallContext, + _request: HostJamPeerTransportEventsRequest, + ) -> Result> + { + Err(CallError::Domain(HostJamPeerTransportEventsError::V1( + v01::HostJamPeerTransportEventsError::NotGranted, + ))) + } +} diff --git a/rust/crates/truapi/src/api/peer_transport.rs b/rust/crates/truapi/src/api/peer_transport.rs deleted file mode 100644 index e267589018..0000000000 --- a/rust/crates/truapi/src/api/peer_transport.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! Unified [`PeerTransport`] trait. - -use crate::versioned::peer_transport::{ - HostPeerTransportCloseError, HostPeerTransportCloseRequest, HostPeerTransportCloseResponse, - HostPeerTransportDialError, HostPeerTransportDialRequest, HostPeerTransportDialResponse, - HostPeerTransportEventsError, HostPeerTransportEventsRequest, HostPeerTransportEventsResponse, - HostPeerTransportOpenError, HostPeerTransportOpenRequest, HostPeerTransportOpenResponse, - HostPeerTransportRecvError, HostPeerTransportRecvRequest, HostPeerTransportRecvResponse, - HostPeerTransportResetError, HostPeerTransportResetRequest, HostPeerTransportResetResponse, - HostPeerTransportSendError, HostPeerTransportSendRequest, HostPeerTransportSendResponse, -}; -use crate::{CallContext, CallError, v01, wire, wire_trait}; - -/// Host-terminated QUIC/WebTransport streams to JAM peers (JAMNP-S). -/// -/// The host owns TLS, certificate verification and length framing; the guest -/// verifies every byte it consumes. Access is a runtime permission, not a -/// manifest declaration: `dial` requires -/// [`RemotePermission::JamPeers`](crate::v01::RemotePermission::JamPeers) for -/// its `genesis`, checking the product's stored decision, prompting when it is -/// undetermined and persisting the answer per product and genesis. The other -/// methods act only on connections a granted `dial` opened. A grant is -/// separate from account, signing and storage authority. -#[wire_trait(id = 23)] -#[crate::async_trait] -pub trait PeerTransport: Send + Sync { - /// Dial one peer. The host builds the ALPN from `genesis` and requires the - /// peer certificate to carry `ed25519` (QUIC) or to hash to the - /// certificate derived from `p256` (WebTransport). - /// - /// ```ts - /// const result = await truapi.peerTransport.dial({ - /// genesis: "0x353963b9cedfe4ea22038081052a5c151b06b55a4a026a97522cd0320cabf49f", - /// ip: "0x00000000000000000000ffff7f000001", - /// port: 43000, - /// ed25519: "0x0000000000000000000000000000000000000000000000000000000000000000", - /// p256: undefined, - /// }); - /// if (result.isOk()) console.log("connection:", result.value.conn); - /// ``` - #[wire(id = 0)] - async fn dial( - &self, - _cx: &CallContext, - _request: HostPeerTransportDialRequest, - ) -> Result> { - Err(CallError::Domain(HostPeerTransportDialError::V1( - v01::HostPeerTransportDialError::NotGranted, - ))) - } - - /// Open a bidirectional stream on a connection and send its kind byte. - /// - /// ```ts - /// const result = await truapi.peerTransport.open({ conn: 0, kind: 0 }); - /// if (result.isOk()) console.log("stream:", result.value.stream); - /// ``` - #[wire(id = 1)] - async fn open( - &self, - _cx: &CallContext, - _request: HostPeerTransportOpenRequest, - ) -> Result> { - Err(CallError::Domain(HostPeerTransportOpenError::V1( - v01::HostPeerTransportOpenError::NotGranted, - ))) - } - - /// Queue one message; the host prepends the `u32` little-endian length. - /// - /// ```ts - /// const result = await truapi.peerTransport.send({ stream: 0, message: "0x00", fin: false }); - /// console.log("sent:", result.isOk()); - /// ``` - #[wire(id = 2)] - async fn send( - &self, - _cx: &CallContext, - _request: HostPeerTransportSendRequest, - ) -> Result> { - Err(CallError::Domain(HostPeerTransportSendError::V1( - v01::HostPeerTransportSendError::Closed, - ))) - } - - /// Poll one complete message without blocking; the host strips the length. - /// - /// ```ts - /// const result = await truapi.peerTransport.recv({ stream: 0, max: 1048576 }); - /// if (result.isOk()) console.log("message:", result.value.message, "fin:", result.value.fin); - /// ``` - #[wire(id = 3)] - async fn recv( - &self, - _cx: &CallContext, - _request: HostPeerTransportRecvRequest, - ) -> Result> { - Err(CallError::Domain(HostPeerTransportRecvError::V1( - v01::HostPeerTransportRecvError::Closed, - ))) - } - - /// Abort a stream in both directions. - /// - /// ```ts - /// const result = await truapi.peerTransport.reset({ stream: 0 }); - /// console.log("reset:", result.isOk()); - /// ``` - #[wire(id = 4)] - async fn reset( - &self, - _cx: &CallContext, - _request: HostPeerTransportResetRequest, - ) -> Result> { - Err(CallError::Domain(HostPeerTransportResetError::V1( - v01::HostPeerTransportResetError::Closed, - ))) - } - - /// Close a connection and every stream on it. - /// - /// ```ts - /// const result = await truapi.peerTransport.close({ conn: 0 }); - /// console.log("closed:", result.isOk()); - /// ``` - #[wire(id = 5)] - async fn close( - &self, - _cx: &CallContext, - _request: HostPeerTransportCloseRequest, - ) -> Result> { - Err(CallError::Domain(HostPeerTransportCloseError::V1( - v01::HostPeerTransportCloseError::Closed, - ))) - } - - /// Drain connection, stream-finish and inbound-stream events. - /// - /// ```ts - /// const result = await truapi.peerTransport.events(); - /// if (result.isOk()) console.log("events:", result.value.events); - /// ``` - #[wire(id = 6)] - async fn events( - &self, - _cx: &CallContext, - _request: HostPeerTransportEventsRequest, - ) -> Result> { - Err(CallError::Domain(HostPeerTransportEventsError::V1( - v01::HostPeerTransportEventsError::NotGranted, - ))) - } -} diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index ac4a610467..2e0727b3f7 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -76,18 +76,21 @@ pub mod latest { HostAccountCreateProofRequest, HostAccountGetAliasRequest, HostAccountListRingVrfKeysRequest, HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest, HostAccountSignVrfError, HostAccountSignVrfRequest, - HostPeerTransportCloseError, HostPeerTransportCloseRequest, HostPeerTransportDialError, - HostPeerTransportDialRequest, HostPeerTransportDialResponse, HostPeerTransportEventsError, - HostPeerTransportEventsResponse, HostPeerTransportOpenError, HostPeerTransportOpenRequest, - HostPeerTransportOpenResponse, HostPeerTransportRecvError, HostPeerTransportRecvRequest, - HostPeerTransportRecvResponse, HostPeerTransportResetError, HostPeerTransportResetRequest, - HostPeerTransportSendError, HostPeerTransportSendRequest, HostPlatform, - HostSignPayloadData, HostWorkerOperationError, ImageFit, ImageProps, ImageSource, Modifier, - NotificationId, OperationId, OperationStartedResult, - PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION, PEER_TRANSPORT_MAX_CONNECTIONS, - PEER_TRANSPORT_MAX_MESSAGE_BYTES, PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION, - PeerTransportEvent, PocketCard, ProductAccountId, ProductProofContext, RawPayload, - RegisteredRingVrfKey, RemotePermission, RemoteStatementStoreCreateProofError, + HostJamPeerTransportCloseError, HostJamPeerTransportCloseRequest, + HostJamPeerTransportDialError, HostJamPeerTransportDialRequest, + HostJamPeerTransportDialResponse, HostJamPeerTransportEventsError, + HostJamPeerTransportEventsResponse, HostJamPeerTransportOpenError, + HostJamPeerTransportOpenRequest, HostJamPeerTransportOpenResponse, + HostJamPeerTransportRecvError, HostJamPeerTransportRecvRequest, + HostJamPeerTransportRecvResponse, HostJamPeerTransportResetError, + HostJamPeerTransportResetRequest, HostJamPeerTransportSendError, + HostJamPeerTransportSendRequest, HostPlatform, HostSignPayloadData, + HostWorkerOperationError, ImageFit, ImageProps, ImageSource, + JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION, JAM_PEER_TRANSPORT_MAX_CONNECTIONS, + JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES, JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION, + JamPeerTransportEvent, Modifier, NotificationId, OperationId, OperationStartedResult, + PocketCard, ProductAccountId, ProductProofContext, RawPayload, RegisteredRingVrfKey, + RemotePermission, RemoteStatementStoreCreateProofError, RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, RemoteStatementStoreSubscribeItem, RemoteStatementStoreSubscribeRequest, RenderContext, RendererNode, RingLocation, RingLocationJunction, RingVrfKeyDisclosure, RingVrfPublicKey, diff --git a/rust/crates/truapi/src/v01.rs b/rust/crates/truapi/src/v01.rs index acba68ff3d..a00fb88749 100644 --- a/rust/crates/truapi/src/v01.rs +++ b/rust/crates/truapi/src/v01.rs @@ -6,11 +6,11 @@ mod chat; mod coin_payment; mod common; mod entropy; +mod jam_peer_transport; mod local_storage; mod locale; mod notifications; mod payment; -mod peer_transport; mod permissions; mod pocket; mod preimage; @@ -29,11 +29,11 @@ pub use chat::*; pub use coin_payment::*; pub use common::*; pub use entropy::*; +pub use jam_peer_transport::*; pub use local_storage::*; pub use locale::*; pub use notifications::*; pub use payment::*; -pub use peer_transport::*; pub use permissions::*; pub use pocket::*; pub use preimage::*; diff --git a/rust/crates/truapi/src/v01/peer_transport.rs b/rust/crates/truapi/src/v01/jam_peer_transport.rs similarity index 81% rename from rust/crates/truapi/src/v01/peer_transport.rs rename to rust/crates/truapi/src/v01/jam_peer_transport.rs index 3b047acdc8..e37730e82e 100644 --- a/rust/crates/truapi/src/v01/peer_transport.rs +++ b/rust/crates/truapi/src/v01/jam_peer_transport.rs @@ -1,18 +1,18 @@ use alloc::vec::Vec; use parity_scale_codec::{Decode, Encode}; -/// Host-side limits every `PeerTransport` implementation enforces. -pub const PEER_TRANSPORT_MAX_CONNECTIONS: u32 = 8; +/// Host-side limits every `JamPeerTransport` implementation enforces. +pub const JAM_PEER_TRANSPORT_MAX_CONNECTIONS: u32 = 8; /// Streams one execution may hold open per connection. -pub const PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION: u32 = 16; +pub const JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION: u32 = 16; /// Largest framed message accepted by `send` or delivered by `recv`. -pub const PEER_TRANSPORT_MAX_MESSAGE_BYTES: u32 = 1 << 20; +pub const JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES: u32 = 1 << 20; /// Bytes the host buffers per connection before applying back-pressure. -pub const PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION: u32 = 4 << 20; +pub const JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION: u32 = 4 << 20; /// Failure to dial a JAM peer. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum HostPeerTransportDialError { +pub enum HostJamPeerTransportDialError { /// The product holds no `RemotePermission::JamPeers` grant for the /// requested genesis, or this host offers no peer transport. NotGranted, @@ -27,7 +27,7 @@ pub enum HostPeerTransportDialError { /// Dial one JAM peer over JAMNP-S (QUIC) or WebTransport. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HostPeerTransportDialRequest { +pub struct HostJamPeerTransportDialRequest { /// Genesis header hash; the host derives the ALPN from it and requires a /// `RemotePermission::JamPeers` grant for it. pub genesis: [u8; 32], @@ -43,14 +43,14 @@ pub struct HostPeerTransportDialRequest { /// An open connection handle. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HostPeerTransportDialResponse { +pub struct HostJamPeerTransportDialResponse { /// Execution-local connection id. pub conn: u32, } /// Failure to open a stream. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum HostPeerTransportOpenError { +pub enum HostJamPeerTransportOpenError { /// This execution has no peer-transport grant. NotGranted, /// The connection is closed or unknown. @@ -61,7 +61,7 @@ pub enum HostPeerTransportOpenError { /// Open a bidirectional stream and send its kind byte. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HostPeerTransportOpenRequest { +pub struct HostJamPeerTransportOpenRequest { /// Connection returned by `dial`. pub conn: u32, /// JAMNP-S stream kind (UP 0, CE 128, ...). @@ -70,14 +70,14 @@ pub struct HostPeerTransportOpenRequest { /// An open stream handle. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HostPeerTransportOpenResponse { +pub struct HostJamPeerTransportOpenResponse { /// Execution-local stream id. pub stream: u32, } /// Failure to send a message. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum HostPeerTransportSendError { +pub enum HostJamPeerTransportSendError { /// The stream is closed, finished or unknown. Closed, /// The message exceeds the host's message limit. @@ -88,7 +88,7 @@ pub enum HostPeerTransportSendError { /// Send one framed message; the host adds the `u32` little-endian length. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HostPeerTransportSendRequest { +pub struct HostJamPeerTransportSendRequest { /// Stream returned by `open` or reported by an `Accepted` event. pub stream: u32, /// Message bytes without length prefix. @@ -99,14 +99,14 @@ pub struct HostPeerTransportSendRequest { /// Failure to receive from a stream. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum HostPeerTransportRecvError { +pub enum HostJamPeerTransportRecvError { /// The stream is unknown or already fully consumed. Closed, } /// Poll one complete framed message without blocking. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HostPeerTransportRecvRequest { +pub struct HostJamPeerTransportRecvRequest { /// Stream to read from. pub stream: u32, /// Largest message the caller accepts. @@ -115,7 +115,7 @@ pub struct HostPeerTransportRecvRequest { /// One unframed message, or none available yet. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HostPeerTransportRecvResponse { +pub struct HostJamPeerTransportRecvResponse { /// Complete message bytes without length prefix, or `None` when nothing /// has arrived yet. pub message: Option>, @@ -127,42 +127,42 @@ pub struct HostPeerTransportRecvResponse { /// Failure to reset a stream. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum HostPeerTransportResetError { +pub enum HostJamPeerTransportResetError { /// The stream is unknown or already closed. Closed, } /// Abort both directions of a stream. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HostPeerTransportResetRequest { +pub struct HostJamPeerTransportResetRequest { /// Stream to reset. pub stream: u32, } /// Failure to close a connection. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum HostPeerTransportCloseError { +pub enum HostJamPeerTransportCloseError { /// The connection is unknown or already closed. Closed, } /// Close a connection and every stream on it. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HostPeerTransportCloseRequest { +pub struct HostJamPeerTransportCloseRequest { /// Connection to close. pub conn: u32, } /// Failure to drain events. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum HostPeerTransportEventsError { +pub enum HostJamPeerTransportEventsError { /// This execution has no peer-transport grant. NotGranted, } /// Asynchronous transport notification. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum PeerTransportEvent { +pub enum JamPeerTransportEvent { /// The connection was closed by the peer or the host. ConnClosed { /// Connection that closed. @@ -186,7 +186,7 @@ pub enum PeerTransportEvent { /// Events in arrival order. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HostPeerTransportEventsResponse { +pub struct HostJamPeerTransportEventsResponse { /// Pending events; empty when nothing happened. - pub events: Vec, + pub events: Vec, } diff --git a/rust/crates/truapi/src/v01/permissions.rs b/rust/crates/truapi/src/v01/permissions.rs index 3fb1fc43a5..9a71e62ee3 100644 --- a/rust/crates/truapi/src/v01/permissions.rs +++ b/rust/crates/truapi/src/v01/permissions.rs @@ -51,7 +51,7 @@ pub enum HostDevicePermissionRequest { /// /// `ChainSubmit`, `PreimageSubmit`, `StatementSubmit` and `JamPeers` are also /// triggered implicitly by the corresponding business calls when not yet -/// granted (`PeerTransport::dial` for `JamPeers`). +/// granted (`JamPeerTransport::dial` for `JamPeers`). #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, Display)] #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] pub enum RemotePermission { @@ -85,7 +85,7 @@ pub enum RemotePermission { #[display("submit statements")] StatementSubmit, /// Read-only peer access over JAMNP-S QUIC/WebTransport to the validators - /// of one JAM chain, through the `PeerTransport` service. + /// of one JAM chain, through the `JamPeerTransport` service. /// /// The app names the endpoints it dials; the grant covers only peers of /// `genesis`. Every byte received is untrusted, and the grant carries no diff --git a/rust/crates/truapi/src/versioned.rs b/rust/crates/truapi/src/versioned.rs index e2c808b49b..e4e0692d85 100644 --- a/rust/crates/truapi/src/versioned.rs +++ b/rust/crates/truapi/src/versioned.rs @@ -39,11 +39,11 @@ pub mod chain; pub mod chat; pub mod coin_payment; pub mod entropy; +pub mod jam_peer_transport; pub mod local_storage; pub mod locale; pub mod notifications; pub mod payment; -pub mod peer_transport; pub mod permissions; pub mod pocket; pub mod preimage; diff --git a/rust/crates/truapi/src/versioned/jam_peer_transport.rs b/rust/crates/truapi/src/versioned/jam_peer_transport.rs new file mode 100644 index 0000000000..ccadbdad35 --- /dev/null +++ b/rust/crates/truapi/src/versioned/jam_peer_transport.rs @@ -0,0 +1,27 @@ +//! Versioned wrappers for [`JamPeerTransport`](crate::api::JamPeerTransport) methods. + +use crate::v01; + +truapi_macros::versioned_type! { + pub enum HostJamPeerTransportDialRequest { V1 => v01::HostJamPeerTransportDialRequest } + pub enum HostJamPeerTransportDialResponse { V1 => v01::HostJamPeerTransportDialResponse } + pub enum HostJamPeerTransportDialError { V1 => v01::HostJamPeerTransportDialError } + pub enum HostJamPeerTransportOpenRequest { V1 => v01::HostJamPeerTransportOpenRequest } + pub enum HostJamPeerTransportOpenResponse { V1 => v01::HostJamPeerTransportOpenResponse } + pub enum HostJamPeerTransportOpenError { V1 => v01::HostJamPeerTransportOpenError } + pub enum HostJamPeerTransportSendRequest { V1 => v01::HostJamPeerTransportSendRequest } + pub enum HostJamPeerTransportSendResponse { V1 } + pub enum HostJamPeerTransportSendError { V1 => v01::HostJamPeerTransportSendError } + pub enum HostJamPeerTransportRecvRequest { V1 => v01::HostJamPeerTransportRecvRequest } + pub enum HostJamPeerTransportRecvResponse { V1 => v01::HostJamPeerTransportRecvResponse } + pub enum HostJamPeerTransportRecvError { V1 => v01::HostJamPeerTransportRecvError } + pub enum HostJamPeerTransportResetRequest { V1 => v01::HostJamPeerTransportResetRequest } + pub enum HostJamPeerTransportResetResponse { V1 } + pub enum HostJamPeerTransportResetError { V1 => v01::HostJamPeerTransportResetError } + pub enum HostJamPeerTransportCloseRequest { V1 => v01::HostJamPeerTransportCloseRequest } + pub enum HostJamPeerTransportCloseResponse { V1 } + pub enum HostJamPeerTransportCloseError { V1 => v01::HostJamPeerTransportCloseError } + pub enum HostJamPeerTransportEventsRequest { V1 } + pub enum HostJamPeerTransportEventsResponse { V1 => v01::HostJamPeerTransportEventsResponse } + pub enum HostJamPeerTransportEventsError { V1 => v01::HostJamPeerTransportEventsError } +} diff --git a/rust/crates/truapi/src/versioned/peer_transport.rs b/rust/crates/truapi/src/versioned/peer_transport.rs deleted file mode 100644 index c80553282a..0000000000 --- a/rust/crates/truapi/src/versioned/peer_transport.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! Versioned wrappers for [`PeerTransport`](crate::api::PeerTransport) methods. - -use crate::v01; - -truapi_macros::versioned_type! { - pub enum HostPeerTransportDialRequest { V1 => v01::HostPeerTransportDialRequest } - pub enum HostPeerTransportDialResponse { V1 => v01::HostPeerTransportDialResponse } - pub enum HostPeerTransportDialError { V1 => v01::HostPeerTransportDialError } - pub enum HostPeerTransportOpenRequest { V1 => v01::HostPeerTransportOpenRequest } - pub enum HostPeerTransportOpenResponse { V1 => v01::HostPeerTransportOpenResponse } - pub enum HostPeerTransportOpenError { V1 => v01::HostPeerTransportOpenError } - pub enum HostPeerTransportSendRequest { V1 => v01::HostPeerTransportSendRequest } - pub enum HostPeerTransportSendResponse { V1 } - pub enum HostPeerTransportSendError { V1 => v01::HostPeerTransportSendError } - pub enum HostPeerTransportRecvRequest { V1 => v01::HostPeerTransportRecvRequest } - pub enum HostPeerTransportRecvResponse { V1 => v01::HostPeerTransportRecvResponse } - pub enum HostPeerTransportRecvError { V1 => v01::HostPeerTransportRecvError } - pub enum HostPeerTransportResetRequest { V1 => v01::HostPeerTransportResetRequest } - pub enum HostPeerTransportResetResponse { V1 } - pub enum HostPeerTransportResetError { V1 => v01::HostPeerTransportResetError } - pub enum HostPeerTransportCloseRequest { V1 => v01::HostPeerTransportCloseRequest } - pub enum HostPeerTransportCloseResponse { V1 } - pub enum HostPeerTransportCloseError { V1 => v01::HostPeerTransportCloseError } - pub enum HostPeerTransportEventsRequest { V1 } - pub enum HostPeerTransportEventsResponse { V1 => v01::HostPeerTransportEventsResponse } - pub enum HostPeerTransportEventsError { V1 => v01::HostPeerTransportEventsError } -} From 5bee953e15b5ee1438739266c1c5f964619e4023 Mon Sep 17 00:00:00 2001 From: w Date: Sun, 27 Sep 2026 22:10:46 -0400 Subject: [PATCH 07/36] refactor(truapi): move JamPeerTransport to wire trait 111 Trait ids on main run 1-19 and are handed out in merge order: open PRs already claim 20 (Contacts, Game) and 22 (Profile), and the next ones are likely to take 21-24. JamPeerTransport is unmerged, so moving it clear of that range now costs no deployed compatibility. Methods 0..6 and every SCALE payload are unchanged; only the trait byte moves from 23 to 111. --- .changeset/pvm-jam-peer-transport.md | 2 +- .../truapi/src/jam-peer-transport-wire.test.ts | 8 ++++---- rust/crates/truapi-client/src/generated.rs | 16 ++++++++-------- .../tests/jam_peer_transport_contract.rs | 6 +++--- rust/crates/truapi/src/api/jam_peer_transport.rs | 2 +- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.changeset/pvm-jam-peer-transport.md b/.changeset/pvm-jam-peer-transport.md index 9a7747956a..66dc331666 100644 --- a/.changeset/pvm-jam-peer-transport.md +++ b/.changeset/pvm-jam-peer-transport.md @@ -3,7 +3,7 @@ "@parity/truapi-host": minor --- -Add the `JamPeerTransport` host service (trait 23): host-terminated JAMNP-S QUIC or WebTransport streams to JAM peers +Add the `JamPeerTransport` host service (trait 111): host-terminated JAMNP-S QUIC or WebTransport streams to JAM peers with `dial`, `open`, `send`, `recv`, `reset`, `close` and `events`. Access is the runtime permission `RemotePermission::JamPeers { genesis }`, appended as variant 5: before a `dial` connects, the host checks the product's stored decision, prompts when it is undetermined and persists the answer per product and genesis. App diff --git a/js/packages/truapi/src/jam-peer-transport-wire.test.ts b/js/packages/truapi/src/jam-peer-transport-wire.test.ts index 222f795a14..ab6cddfc05 100644 --- a/js/packages/truapi/src/jam-peer-transport-wire.test.ts +++ b/js/packages/truapi/src/jam-peer-transport-wire.test.ts @@ -17,11 +17,11 @@ import { decodeWireMessage, encodeWireMessage } from "./transport.js"; const GENESIS = "0x353963b9cedfe4ea22038081052a5c151b06b55a4a026a97522cd0320cabf49f" as const; const LOOPBACK_V4_MAPPED = "0x00000000000000000000ffff7f000001" as const; -test("JamPeerTransport is namespace 23 with methods 0..6 in contract order", () => { +test("JamPeerTransport is namespace 111 with methods 0..6 in contract order", () => { const ids = [JAM_PEER_TRANSPORT_DIAL, JAM_PEER_TRANSPORT_OPEN, JAM_PEER_TRANSPORT_SEND, JAM_PEER_TRANSPORT_RECV, JAM_PEER_TRANSPORT_RESET, JAM_PEER_TRANSPORT_CLOSE, JAM_PEER_TRANSPORT_EVENTS]; ids.forEach((id, method) => { - expect(id.trait).toBe(23); + expect(id.trait).toBe(111); expect(id.method).toBe(method); expect(id.kind).toBe("request"); }); @@ -86,9 +86,9 @@ test("send, recv and events payloads match the Rust SCALE bytes", () => { test("a NotGranted dial response decodes from a host frame", () => { const resultCodec = S.Result(T.VersionedHostJamPeerTransportDialResponse, S.CallError(T.VersionedHostJamPeerTransportDialError)); const value = resultCodec.enc({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "NotGranted" } } }); - const frame = encodeWireMessage({ requestId: "p", payload: { traitId: 23, methodId: 0, messageType: 1, value } }); + const frame = encodeWireMessage({ requestId: "p", payload: { traitId: 111, methodId: 0, messageType: 1, value } }); if (frame.isErr()) throw frame.error; - expect([...frame.value]).toEqual([4, 112, 23, 0, 1, 1, 0, 0, 0]); + expect([...frame.value]).toEqual([4, 112, 111, 0, 1, 1, 0, 0, 0]); const decoded = decodeWireMessage(frame.value); if (decoded.isErr()) throw decoded.error; expect(decoded.value.requestId).toBe("p"); diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index 6780c6efab..f98b6b93c9 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "11e5fe21ba1c8220"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "03ed2f7272b4248c"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; @@ -1102,7 +1102,7 @@ impl JamPeerTransportDial { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 23, + trait_id: 111, method_id: 0, }), }; @@ -1129,7 +1129,7 @@ impl JamPeerTransportOpen { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 23, + trait_id: 111, method_id: 1, }), }; @@ -1156,7 +1156,7 @@ impl JamPeerTransportSend { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 23, + trait_id: 111, method_id: 2, }), }; @@ -1183,7 +1183,7 @@ impl JamPeerTransportRecv { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 23, + trait_id: 111, method_id: 3, }), }; @@ -1210,7 +1210,7 @@ impl JamPeerTransportReset { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 23, + trait_id: 111, method_id: 4, }), }; @@ -1237,7 +1237,7 @@ impl JamPeerTransportClose { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 23, + trait_id: 111, method_id: 5, }), }; @@ -1264,7 +1264,7 @@ impl JamPeerTransportEvents { direction: Direction::ProductToHost, required_execution: None, wire: MethodWire::Request(MethodIds { - trait_id: 23, + trait_id: 111, method_id: 6, }), }; diff --git a/rust/crates/truapi-server/tests/jam_peer_transport_contract.rs b/rust/crates/truapi-server/tests/jam_peer_transport_contract.rs index 552ac827c6..a44d818a04 100644 --- a/rust/crates/truapi-server/tests/jam_peer_transport_contract.rs +++ b/rust/crates/truapi-server/tests/jam_peer_transport_contract.rs @@ -1,6 +1,6 @@ //! JamPeerTransport contract regression test. //! -//! Pins the frozen trait-23 wire ids and SCALE layouts, the `JamPeers` +//! Pins the frozen trait-111 wire ids and SCALE layouts, the `JamPeers` //! permission's place in `RemotePermission`, and the genesis/ALPN helpers a //! host uses on dial. @@ -67,7 +67,7 @@ fn jam_peers_is_the_last_remote_permission_and_names_its_genesis() { assert_eq!(jam.to_string(), "connections to JAM network 0x353963b9…"); } -/// The frozen contract: namespace 23, methods 0..6 in this order, V1 payloads. +/// The frozen contract: namespace 111, methods 0..6 in this order, V1 payloads. #[test] fn the_wire_ids_and_scale_layout_match_the_frozen_contract() { for (ids, method_id) in [ @@ -82,7 +82,7 @@ fn the_wire_ids_and_scale_layout_match_the_frozen_contract() { assert_eq!( ids, MethodIds { - trait_id: 23, + trait_id: 111, method_id } ); diff --git a/rust/crates/truapi/src/api/jam_peer_transport.rs b/rust/crates/truapi/src/api/jam_peer_transport.rs index 5ca2aa519c..e468ffccc9 100644 --- a/rust/crates/truapi/src/api/jam_peer_transport.rs +++ b/rust/crates/truapi/src/api/jam_peer_transport.rs @@ -25,7 +25,7 @@ use crate::{CallContext, CallError, v01, wire, wire_trait}; /// undetermined and persisting the answer per product and genesis. The other /// methods act only on connections a granted `dial` opened. A grant is /// separate from account, signing and storage authority. -#[wire_trait(id = 23)] +#[wire_trait(id = 111)] #[crate::async_trait] pub trait JamPeerTransport: Send + Sync { /// Dial one peer. The host builds the ALPN from `genesis` and requires the From 67156febaf5bed1a1ae4ab2501925df3d7cc61c4 Mon Sep 17 00:00:00 2001 From: w Date: Mon, 28 Sep 2026 21:48:56 -0400 Subject: [PATCH 08/36] feat(truapi-server): serve JamPeerTransport natively over JAMNP-S QUIC Native product runtimes (iOS, Android, CLI) now implement JamPeerTransport themselves instead of answering NotGranted. The JAMNP-S QUIC client is ported from jam-explore's tested jam-peer-transport-native crate (quinn + rustls/ring, self-signed Ed25519 identity pinned by the peer's JAMNP-S alternative name, u32-LE framing, per-execution caps) into truapi_server::jam_peer_transport, native targets only. Each product connection owns one session. A dial runs require_jam_peers once per genesis on the runtime spawner, so concurrent dials share one prompt, a refusal is remembered, and an answer given after the dial gave up is still persisted. A dial answers within 10 s including the prompt, honours CANCEL, and never holds a slot past its deadline. The endpoint is created by the first granted dial, so a refused product binds no socket; dispose closes all peer connections and flushes their close frames. recv reports Closed once the end of a stream has been consumed, and undrained events are capped at 1024, matching the browser session. The iOS and Android product bridges route RemotePermission::JamPeers through their existing remote-permission prompt and storage, per product and genesis. Adds tests/live_jam_public_devnet.rs: an ignored test that dials all six public-devnet validators through ProductRuntime frames, completes UP 0 and waits for block announcements, and a Linux test proving a refused product opens no UDP socket. --- .changeset/pvm-jam-peer-transport.md | 6 +- Cargo.lock | 168 +++- .../common/src/main/res/values/strings.xml | 4 + .../domain/hostApi/HostApiInteractor.kt | 1 + .../handlers/RemotePermissionHandler.kt | 9 +- .../permissions/models/ProductPermission.kt | 16 + .../models/RemotePermissionRequest.kt | 1 + .../domain/truapi/ProductTrUAPIHostBridge.kt | 10 +- .../compose/PermissionMapping.kt | 4 + .../components/ProductPermissionItem.kt | 2 + .../Guard/ProductPermissionGuard.swift | 2 + .../Handlers/RemotePermissionHandler.swift | 4 +- .../Permissions/Model/ProductPermission.swift | 18 +- .../Model/RemotePermissionRequest.swift | 5 + ...emoteProductPermissionRequesterTests.swift | 3 +- .../Localization/Products.xcstrings | 44 ++ .../ProductPermissionPromptViewFactory.swift | 17 + .../TrUAPI/RustProductExecutionBridge.swift | 13 +- .../AppPermissionsViewModelFactory.swift | 9 + .../TrUAPI/RustRuntimeBridgeTests.swift | 18 + rust/crates/truapi-server/Cargo.toml | 12 +- rust/crates/truapi-server/README.md | 24 + rust/crates/truapi-server/src/core.rs | 56 +- rust/crates/truapi-server/src/host_core.rs | 5 +- .../truapi-server/src/jam_peer_transport.rs | 14 + .../src/jam_peer_transport/peer_id.rs | 44 ++ .../src/jam_peer_transport/quic.rs | 730 ++++++++++++++++++ .../src/jam_peer_transport/session.rs | 372 +++++++++ .../src/jam_peer_transport/session/tests.rs | 447 +++++++++++ .../src/jam_peer_transport/tls.rs | 230 ++++++ rust/crates/truapi-server/src/runtime.rs | 75 +- .../truapi-server/src/runtime/capabilities.rs | 1 + .../capabilities/jam_peer_transport.rs | 116 +++ .../src/runtime/capabilities/resources.rs | 4 - .../tests/live_jam_public_devnet.rs | 445 +++++++++++ 35 files changed, 2848 insertions(+), 81 deletions(-) create mode 100644 rust/crates/truapi-server/src/jam_peer_transport/peer_id.rs create mode 100644 rust/crates/truapi-server/src/jam_peer_transport/quic.rs create mode 100644 rust/crates/truapi-server/src/jam_peer_transport/session.rs create mode 100644 rust/crates/truapi-server/src/jam_peer_transport/session/tests.rs create mode 100644 rust/crates/truapi-server/src/jam_peer_transport/tls.rs create mode 100644 rust/crates/truapi-server/src/runtime/capabilities/jam_peer_transport.rs create mode 100644 rust/crates/truapi-server/tests/live_jam_public_devnet.rs diff --git a/.changeset/pvm-jam-peer-transport.md b/.changeset/pvm-jam-peer-transport.md index 66dc331666..99311906db 100644 --- a/.changeset/pvm-jam-peer-transport.md +++ b/.changeset/pvm-jam-peer-transport.md @@ -7,6 +7,8 @@ Add the `JamPeerTransport` host service (trait 111): host-terminated JAMNP-S QUI with `dial`, `open`, `send`, `recv`, `reset`, `close` and `events`. Access is the runtime permission `RemotePermission::JamPeers { genesis }`, appended as variant 5: before a `dial` connects, the host checks the product's stored decision, prompts when it is undetermined and persists the answer per product and genesis. App -manifests declare nothing. The default implementation, including the Rust product runtime, returns `NotGranted`. +manifests declare nothing. The trait's default implementation returns `NotGranted`, and the browser core keeps it. Ships the browser WebTransport adapter, whose `createJamPeerTransportSession({ authorize })` asks once per genesis per -session, and the deterministic PolkaJAM certificate-hash derivation under `@parity/truapi/jam-peer-transport`. +session, and the deterministic PolkaJAM certificate-hash derivation under `@parity/truapi/jam-peer-transport`. Native +Rust product runtimes (iOS, Android, CLI) serve the service over JAMNP-S QUIC with the same session rules, and the iOS +and Android hosts show their remote-permission prompt for `JamPeers`. diff --git a/Cargo.lock b/Cargo.lock index b555b57fe4..721bd1096a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -543,6 +543,45 @@ dependencies = [ "winnow", ] +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.19", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -743,7 +782,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] [[package]] @@ -752,6 +791,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitcoin_hashes" version = "0.14.101" @@ -1486,6 +1534,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1679,7 +1741,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3494,6 +3556,15 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -4000,7 +4071,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4184,6 +4255,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "ring", + "rustls-pki-types", + "time", + "x509-parser 0.18.1", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4311,6 +4395,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.1.4" @@ -4321,7 +4414,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5534,7 +5627,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5631,6 +5724,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -5639,6 +5733,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -6102,7 +6206,11 @@ dependencies = [ "nanoid", "parity-scale-codec", "parking_lot", + "quinn", "rand 0.8.7", + "rcgen", + "ring", + "rustls", "scale-decode", "scale-info", "schnorrkel", @@ -6134,6 +6242,7 @@ dependencies = [ "web-sys", "web-time", "x25519-dalek", + "x509-parser 0.17.0", "zeroize", ] @@ -6834,7 +6943,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f0aa306497a238d169b9dc70659105b4a096859a34894544ca81719242e1499" dependencies = [ "arrayvec 0.7.8", - "bit-vec", + "bit-vec 0.8.0", "bitflags 2.13.1", "cfg_aliases", "document-features", @@ -6932,7 +7041,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7218,6 +7327,41 @@ dependencies = [ "zeroize", ] +[[package]] +name = "x509-parser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.19", + "time", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.19", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -7240,6 +7384,16 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfe269e7b803a5e8e20cbd97860e136529cd83bf2c9c6d37b142467e7e1f051f" +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/hosts/android/common/src/main/res/values/strings.xml b/hosts/android/common/src/main/res/values/strings.xml index 57a583f8f8..2e11f0d6aa 100644 --- a/hosts/android/common/src/main/res/values/strings.xml +++ b/hosts/android/common/src/main/res/values/strings.xml @@ -901,12 +901,15 @@ I’m attaching PDF with more information and details regarding the Tattoo Stenc Statements will be published to the statement store. %1$s %1$s would like to submit preimages Preimages will be uploaded to the bulletin chain. %1$s + %1$s would like to connect to JAM network %2$s + Read-only peer access to this network\'s validators, with no accounts or signing. %1$s %1$s Would Like Access to: Access %1$s Use WebRTC Submit transactions Submit statements Submit preimages + Connect to JAM network %1$s Allow always all Allow once all Deny all @@ -1359,6 +1362,7 @@ I’m attaching PDF with more information and details regarding the Tattoo Stenc Chain transactions Statement submission Preimage submission + JAM network peers Remote permission Chat messages diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/HostApiInteractor.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/HostApiInteractor.kt index 5a708807bf..e6a9c8b0a8 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/HostApiInteractor.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/HostApiInteractor.kt @@ -490,4 +490,5 @@ private fun RemotePermissionRequest.toDomainPermissions(): List listOf(ProductPermission.RemotePermission.ChainSubmitAccess) RemotePermissionRequest.StatementSubmit -> listOf(ProductPermission.RemotePermission.StatementSubmitAccess) RemotePermissionRequest.PreimageSubmit -> listOf(ProductPermission.RemotePermission.PreimageSubmitAccess) + is RemotePermissionRequest.JamPeers -> listOf(ProductPermission.RemotePermission.JamPeersAccess(genesis)) } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/handlers/RemotePermissionHandler.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/handlers/RemotePermissionHandler.kt index 4d41c3586f..9d2c01e5a8 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/handlers/RemotePermissionHandler.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/handlers/RemotePermissionHandler.kt @@ -18,7 +18,8 @@ class RemotePermissionHandler @Inject constructor( is RemotePermission.WebRtcAccess, is RemotePermission.ChainSubmitAccess, is RemotePermission.StatementSubmitAccess, - is RemotePermission.PreimageSubmitAccess -> repository.isGranted(productId, permission) + is RemotePermission.PreimageSubmitAccess, + is RemotePermission.JamPeersAccess -> repository.isGranted(productId, permission) } } @@ -28,7 +29,8 @@ class RemotePermissionHandler @Inject constructor( is RemotePermission.WebRtcAccess, is RemotePermission.ChainSubmitAccess, is RemotePermission.StatementSubmitAccess, - is RemotePermission.PreimageSubmitAccess -> requestSimple(productId, permission) + is RemotePermission.PreimageSubmitAccess, + is RemotePermission.JamPeersAccess -> requestSimple(productId, permission) } } @@ -38,7 +40,8 @@ class RemotePermissionHandler @Inject constructor( is RemotePermission.WebRtcAccess, is RemotePermission.ChainSubmitAccess, is RemotePermission.StatementSubmitAccess, - is RemotePermission.PreimageSubmitAccess -> repository.revoke(productId, permission) + is RemotePermission.PreimageSubmitAccess, + is RemotePermission.JamPeersAccess -> repository.revoke(productId, permission) } } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/models/ProductPermission.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/models/ProductPermission.kt index 5a81e0ab36..25cac4e0a7 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/models/ProductPermission.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/models/ProductPermission.kt @@ -46,6 +46,21 @@ sealed interface ProductPermission { override val typeName: String get() = TYPE_NAME override val key: String get() = "" } + + /** Stored per network; [genesis] is the lowercase `0x`-prefixed genesis hash. */ + data class JamPeersAccess( + val genesis: String, + ) : RemotePermission { + override val typeName: String get() = TYPE_NAME + override val key: String get() = genesis + + /** `0x` plus the first 8 hex digits, as shown in prompts and the permissions list. */ + val shortGenesis: String get() = genesis.take(10) + "…" + + companion object { + const val TYPE_NAME = "jam_peers" + } + } } data class DeviceCapability( @@ -94,6 +109,7 @@ sealed interface ProductPermission { RemotePermission.ChainSubmitAccess.TYPE_NAME -> RemotePermission.ChainSubmitAccess RemotePermission.StatementSubmitAccess.TYPE_NAME -> RemotePermission.StatementSubmitAccess RemotePermission.PreimageSubmitAccess.TYPE_NAME -> RemotePermission.PreimageSubmitAccess + RemotePermission.JamPeersAccess.TYPE_NAME -> RemotePermission.JamPeersAccess(key) else -> error("Unknown permission type: $typeName") } } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/models/RemotePermissionRequest.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/models/RemotePermissionRequest.kt index 2f71dcb014..5e359b689f 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/models/RemotePermissionRequest.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/models/RemotePermissionRequest.kt @@ -6,4 +6,5 @@ sealed interface RemotePermissionRequest { data object ChainSubmit : RemotePermissionRequest data object StatementSubmit : RemotePermissionRequest data object PreimageSubmit : RemotePermissionRequest + data class JamPeers(val genesis: String) : RemotePermissionRequest } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt index 9c4c9e3160..2b655fa7cb 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt @@ -4,6 +4,7 @@ import androidx.core.net.toUri import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import io.novasama.substrate_sdk_android.extensions.toHexString import io.parity.truapi.HostBridge import io.parity.truapi.HostCoreStorage import io.parity.truapi.HostStorage @@ -199,10 +200,8 @@ class ProductTrUAPIHostBridge @AssistedInject constructor( product: ProductExecutionConfig, request: RemotePermission, ): TrUAPIPermissionDecision { - // This app has no JAM peer transport, so it has nothing to grant. - val domainRequest = request.toDomain() ?: return TrUAPIPermissionDecision.DENY return hostApiInteractor - .requestRemotePermissionDecision(callingProductId, domainRequest) + .requestRemotePermissionDecision(callingProductId, request.toDomain()) .getOrElse { throw it } .toNative() } @@ -367,14 +366,13 @@ private fun HostDevicePermissionRequest.toCapability(): DeviceCapabilityType = w HostDevicePermissionRequest.BIOMETRICS -> DeviceCapabilityType.Biometrics } -/** The Products domain request, or `null` for a permission this app has no surface for. */ -private fun RemotePermission.toDomain(): RemotePermissionRequest? = when (this) { +private fun RemotePermission.toDomain(): RemotePermissionRequest = when (this) { is RemotePermission.Remote -> RemotePermissionRequest.Remote(domains) RemotePermission.WebRtc -> RemotePermissionRequest.WebRtc RemotePermission.ChainSubmit -> RemotePermissionRequest.ChainSubmit RemotePermission.PreimageSubmit -> RemotePermissionRequest.PreimageSubmit RemotePermission.StatementSubmit -> RemotePermissionRequest.StatementSubmit - is RemotePermission.JamPeers -> null + is RemotePermission.JamPeers -> RemotePermissionRequest.JamPeers(genesis.toHexString(withPrefix = true)) } private fun PermissionDecision.toNative(): TrUAPIPermissionDecision = when (this) { diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/permissionPrompt/compose/PermissionMapping.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/permissionPrompt/compose/PermissionMapping.kt index 1a2a2f9919..77d35d6fa0 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/permissionPrompt/compose/PermissionMapping.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/permissionPrompt/compose/PermissionMapping.kt @@ -34,6 +34,7 @@ internal val ProductPermission.icon: ImageVector ProductPermission.RemotePermission.ChainSubmitAccess -> NovaIcons.Send ProductPermission.RemotePermission.StatementSubmitAccess -> NovaIcons.CloudOn ProductPermission.RemotePermission.PreimageSubmitAccess -> NovaIcons.CloudOn + is ProductPermission.RemotePermission.JamPeersAccess -> NovaIcons.WiFi } private val DeviceCapabilityType.icon: ImageVector @@ -61,6 +62,7 @@ internal fun ProductPermission.title(productId: String): String { ProductPermission.RemotePermission.ChainSubmitAccess -> stringResource(RCommon.string.product_permission_chain_submit_title, productId) ProductPermission.RemotePermission.StatementSubmitAccess -> stringResource(RCommon.string.product_permission_statement_submit_title, productId) ProductPermission.RemotePermission.PreimageSubmitAccess -> stringResource(RCommon.string.product_permission_preimage_submit_title, productId) + is ProductPermission.RemotePermission.JamPeersAccess -> stringResource(RCommon.string.product_permission_jam_peers_title, productId, shortGenesis) } } @@ -93,6 +95,7 @@ internal fun ProductPermission.subtitle(): String { ProductPermission.RemotePermission.ChainSubmitAccess -> stringResource(RCommon.string.product_permission_chain_submit_subtitle, manageLater) ProductPermission.RemotePermission.StatementSubmitAccess -> stringResource(RCommon.string.product_permission_statement_submit_subtitle, manageLater) ProductPermission.RemotePermission.PreimageSubmitAccess -> stringResource(RCommon.string.product_permission_preimage_submit_subtitle, manageLater) + is ProductPermission.RemotePermission.JamPeersAccess -> stringResource(RCommon.string.product_permission_jam_peers_subtitle, manageLater) } } @@ -120,5 +123,6 @@ internal fun ProductPermission.RemotePermission.shortLabel(): String { ProductPermission.RemotePermission.ChainSubmitAccess -> stringResource(RCommon.string.product_permission_chain_submit_label) ProductPermission.RemotePermission.StatementSubmitAccess -> stringResource(RCommon.string.product_permission_statement_submit_label) ProductPermission.RemotePermission.PreimageSubmitAccess -> stringResource(RCommon.string.product_permission_preimage_submit_label) + is ProductPermission.RemotePermission.JamPeersAccess -> stringResource(RCommon.string.product_permission_jam_peers_label, shortGenesis) } } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/productPermissions/compose/components/ProductPermissionItem.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/productPermissions/compose/components/ProductPermissionItem.kt index d70814a326..3b97dd407f 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/productPermissions/compose/components/ProductPermissionItem.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/productPermissions/compose/components/ProductPermissionItem.kt @@ -64,6 +64,7 @@ private fun ProductPermission.displayName(): String { ProductPermission.RemotePermission.ChainSubmitAccess -> stringResource(RCommon.string.product_permission_type_chain_submit) ProductPermission.RemotePermission.StatementSubmitAccess -> stringResource(RCommon.string.product_permission_type_statement_submit) ProductPermission.RemotePermission.PreimageSubmitAccess -> stringResource(RCommon.string.product_permission_type_preimage_submit) + is ProductPermission.RemotePermission.JamPeersAccess -> stringResource(RCommon.string.product_permission_jam_peers_label, shortGenesis) } } @@ -92,6 +93,7 @@ private fun ProductPermission.descriptionRes(): Int = when (this) { is ProductPermission.BalanceAccess -> RCommon.string.product_permission_type_balance_access_description is ProductPermission.UserIdentityAccess -> RCommon.string.product_permission_type_user_identity_access_description is ProductPermission.RemotePermission.NetworkAccess -> RCommon.string.product_permission_type_network_access + is ProductPermission.RemotePermission.JamPeersAccess -> RCommon.string.product_permission_type_jam_peers ProductPermission.RemotePermission.WebRtcAccess, ProductPermission.RemotePermission.ChainSubmitAccess, ProductPermission.RemotePermission.StatementSubmitAccess, diff --git a/hosts/ios/Packages/Products/Sources/Products/Permissions/Guard/ProductPermissionGuard.swift b/hosts/ios/Packages/Products/Sources/Products/Permissions/Guard/ProductPermissionGuard.swift index a9251331a0..ac1427e9ea 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Permissions/Guard/ProductPermissionGuard.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Permissions/Guard/ProductPermissionGuard.swift @@ -77,6 +77,7 @@ public final class ProductPermissionGuard: ProductPermissionGuarding, @unchecked .chainSubmitAccess, .preimageSubmitAccess, .statementSubmitAccess, + .jamPeersAccess, .userIdentityAccess: try await remoteHandler.request(productId: productId, permission: permission) } @@ -181,6 +182,7 @@ public final class ProductPermissionGuard: ProductPermissionGuarding, @unchecked .chainSubmitAccess, .preimageSubmitAccess, .statementSubmitAccess, + .jamPeersAccess, .userIdentityAccess: try await remoteHandler.isGranted(productId: productId, permission: permission) } diff --git a/hosts/ios/Packages/Products/Sources/Products/Permissions/Handlers/RemotePermissionHandler.swift b/hosts/ios/Packages/Products/Sources/Products/Permissions/Handlers/RemotePermissionHandler.swift index c660721c05..118271e5d9 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Permissions/Handlers/RemotePermissionHandler.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Permissions/Handlers/RemotePermissionHandler.swift @@ -1,8 +1,8 @@ import Foundation /// Handles simple remote permissions (`webRtcAccess`, `chainSubmitAccess`, -/// `preimageSubmitAccess`, `statementSubmitAccess`) with a check-or-prompt -/// pattern. Network-access permissions are routed to +/// `preimageSubmitAccess`, `statementSubmitAccess`, `jamPeersAccess`) with a +/// check-or-prompt pattern. Network-access permissions are routed to /// ``NetworkAccessPermissionHandler`` instead. public final class RemotePermissionHandler: Sendable { private let repository: ProductPermissionRepositoryProtocol diff --git a/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift b/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift index d2f8fd9027..c5e94f1887 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift @@ -12,6 +12,7 @@ public enum ProductPermission: Equatable, Sendable { public static let preimageSubmitAccessTypeName = "preimage_submit" public static let balanceAccessTypeName = "balance_access" public static let statementSubmitAccessTypeName = "statement_submit" + public static let jamPeersAccessTypeName = "jam_peers" public static let userIdentityAccessTypeName = "user_identity_access" case deviceCapability(DeviceCapabilityType) @@ -22,6 +23,9 @@ public enum ProductPermission: Equatable, Sendable { case chainSubmitAccess case preimageSubmitAccess case statementSubmitAccess + /// Read-only peer access to the JAM network whose genesis header hash is + /// `genesis` (lowercase `0x`-prefixed hex). + case jamPeersAccess(genesis: String) case userIdentityAccess /// Whether this is one of the core's `RemotePermission` cases: a product's @@ -34,13 +38,19 @@ public enum ProductPermission: Equatable, Sendable { public var isRemoteAccess: Bool { switch self { case .networkAccess, .webRtcAccess, .chainSubmitAccess, .preimageSubmitAccess, - .statementSubmitAccess: + .statementSubmitAccess, .jamPeersAccess: true case .deviceCapability, .accountAccess, .balanceAccess, .userIdentityAccess: false } } + /// A JAM genesis as shown to the user: `0x`, its first 8 hex digits and `…`. + public static func shortGenesis(_ genesis: String) -> String { + let digits = genesis.hasPrefix("0x") ? genesis.dropFirst(2) : Substring(genesis) + return "0x\(digits.prefix(8))…" + } + public var typeName: String { switch self { case .deviceCapability: @@ -59,6 +69,8 @@ public enum ProductPermission: Equatable, Sendable { Self.preimageSubmitAccessTypeName case .statementSubmitAccess: Self.statementSubmitAccessTypeName + case .jamPeersAccess: + Self.jamPeersAccessTypeName case .userIdentityAccess: Self.userIdentityAccessTypeName } @@ -72,6 +84,8 @@ public enum ProductPermission: Equatable, Sendable { domain case let .accountAccess(targetProductId): targetProductId + case let .jamPeersAccess(genesis): + genesis case .balanceAccess, .webRtcAccess, .chainSubmitAccess, @@ -103,6 +117,8 @@ public enum ProductPermission: Equatable, Sendable { return .preimageSubmitAccess case statementSubmitAccessTypeName: return .statementSubmitAccess + case jamPeersAccessTypeName: + return .jamPeersAccess(genesis: key) case userIdentityAccessTypeName: return .userIdentityAccess default: diff --git a/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/RemotePermissionRequest.swift b/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/RemotePermissionRequest.swift index 5836784730..7603037bef 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/RemotePermissionRequest.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/RemotePermissionRequest.swift @@ -7,6 +7,9 @@ public enum RemotePermissionRequest: Equatable, Sendable { case chainSubmit case preimageSubmit case statementSubmit + /// Read-only peer access to the JAM network whose genesis header hash is + /// `genesis` (lowercase `0x`-prefixed hex). + case jamPeers(genesis: String) /// Converts to domain-level permissions used by the permission guard. /// `Remote` expands into one `networkAccess` per domain (lowercased). @@ -22,6 +25,8 @@ public enum RemotePermissionRequest: Equatable, Sendable { [.preimageSubmitAccess] case .statementSubmit: [.statementSubmitAccess] + case let .jamPeers(genesis): + [.jamPeersAccess(genesis: genesis)] } } } diff --git a/hosts/ios/Packages/Products/Tests/ProductsTests/TrustedRemoteProductPermissionRequesterTests.swift b/hosts/ios/Packages/Products/Tests/ProductsTests/TrustedRemoteProductPermissionRequesterTests.swift index e4f5edc437..af8fe43d1f 100644 --- a/hosts/ios/Packages/Products/Tests/ProductsTests/TrustedRemoteProductPermissionRequesterTests.swift +++ b/hosts/ios/Packages/Products/Tests/ProductsTests/TrustedRemoteProductPermissionRequesterTests.swift @@ -25,7 +25,8 @@ struct TrustedRemoteProductPermissionRequesterTests { .webRtcAccess, .chainSubmitAccess, .preimageSubmitAccess, - .statementSubmitAccess + .statementSubmitAccess, + .jamPeersAccess(genesis: "0x10c123f0" + String(repeating: "0", count: 56)) ] /// The core grants a first-party product every remote permission without diff --git a/hosts/ios/polkadot-app/Localization/Products.xcstrings b/hosts/ios/polkadot-app/Localization/Products.xcstrings index 506317678a..941669a9cf 100644 --- a/hosts/ios/polkadot-app/Localization/Products.xcstrings +++ b/hosts/ios/polkadot-app/Localization/Products.xcstrings @@ -232,6 +232,17 @@ } } }, + "app.permission.jamPeers.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "JAM network peers" + } + } + } + }, "app.permission.network.title": { "extractionState": "manual", "localizations": { @@ -419,6 +430,17 @@ } } }, + "permission.body.jamPeers": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read-only peer access to this network's validators, with no accounts or signing." + } + } + } + }, "permission.body.manageInSettingsHint": { "extractionState": "manual", "localizations": { @@ -683,6 +705,17 @@ } } }, + "permission.label.jamPeers": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect to JAM network %1$(shortGenesis)@" + } + } + } + }, "permission.title.accountAccess": { "extractionState": "manual", "localizations": { @@ -705,6 +738,17 @@ } } }, + "permission.title.jamPeers": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$(productId)@ would like to connect to JAM network %2$(shortGenesis)@" + } + } + } + }, "permission.title.networkAccess": { "extractionState": "manual", "localizations": { diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift b/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift index 78451b066e..ddf0e19581 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift @@ -140,6 +140,17 @@ private extension ProductPermissionPromptViewFactory { body: String(localized: .Products.permissionBodyStatementSubmit), icon: makeIcon(systemName: "text.bubble") ) + case let .jamPeersAccess(genesis): + PromptContent( + title: String( + localized: .Products.permissionTitleJamPeers( + productId: productId, + shortGenesis: ProductPermission.shortGenesis(genesis) + ) + ), + body: String(localized: .Products.permissionBodyJamPeers), + icon: makeIcon(systemName: "point.3.connected.trianglepath.dotted") + ) case .userIdentityAccess: PromptContent( title: String(localized: .Products.permissionTitleRemote(productId: productId)), @@ -178,6 +189,12 @@ private extension ProductPermissionPromptViewFactory { "- " + String(localized: .Products.permissionBodyPreimageSubmit) case .statementSubmitAccess: "- " + String(localized: .Products.permissionBodyStatementSubmit) + case let .jamPeersAccess(genesis): + "- " + String( + localized: .Products.permissionLabelJamPeers( + shortGenesis: ProductPermission.shortGenesis(genesis) + ) + ) case let .deviceCapability(capability): "- " + capabilityDescription(capability) case let .accountAccess(targetProductId): diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift index 9ddb11536a..c041f447a0 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift @@ -97,11 +97,9 @@ class RustProductExecutionBridge: HostBridge, @unchecked Sendable { product _: ProductExecutionConfig, request: RemotePermission ) async throws -> TrUAPIPermissionDecision { - // This app has no JAM peer transport, so it has nothing to grant. - guard let domainRequest = request.toDomainRequest() else { return .deny } - return try await dependencies.permissionGuard.requestPermissionsDecision( + try await dependencies.permissionGuard.requestPermissionsDecision( productId: dependencies.productId, - permissions: domainRequest.toDomainPermissions() + permissions: request.toDomainRequest().toDomainPermissions() ).hostDecision } @@ -232,16 +230,15 @@ extension HostDevicePermissionRequest { } extension RemotePermission { - /// Maps the TrUAPI remote permission to the Products domain request, or - /// `nil` for one this app has no surface for. - func toDomainRequest() -> Products.RemotePermissionRequest? { + /// Maps the TrUAPI remote permission to the Products domain request. + func toDomainRequest() -> Products.RemotePermissionRequest { switch self { case let .remote(domains): .remote(domains: domains) case .webRtc: .webRTC case .chainSubmit: .chainSubmit case .preimageSubmit: .preimageSubmit case .statementSubmit: .statementSubmit - case .jamPeers: nil + case let .jamPeers(genesis): .jamPeers(genesis: genesis.toHex(includePrefix: true)) } } } diff --git a/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift b/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift index f0f736b4c1..99e025ffc1 100644 --- a/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift @@ -77,6 +77,15 @@ private extension AppPermissionsViewModelFactory { String(localized: .Products.appPermissionStatementSubmitTitle), String(localized: .Products.permissionBodyStatementSubmit) ) + case let .jamPeersAccess(genesis): + ( + String(localized: .Products.appPermissionJamPeersTitle), + String( + localized: .Products.permissionLabelJamPeers( + shortGenesis: ProductPermission.shortGenesis(genesis) + ) + ) + ) case .userIdentityAccess: ( String(localized: .Products.appPermissionUserIdentityTitle), diff --git a/hosts/ios/polkadot-appTests/TrUAPI/RustRuntimeBridgeTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/RustRuntimeBridgeTests.swift index 2480962a84..9e14b1bae2 100644 --- a/hosts/ios/polkadot-appTests/TrUAPI/RustRuntimeBridgeTests.swift +++ b/hosts/ios/polkadot-appTests/TrUAPI/RustRuntimeBridgeTests.swift @@ -216,6 +216,24 @@ struct RustRuntimeBridgeTests { #expect(guard_.requestedBatchedPermissions == [.webRtcAccess]) } + /// `remotePermission`: JamPeers asks for access keyed by its genesis as + /// lowercase `0x` hex, so each network is granted separately. + @Test func remotePermissionJamPeers() async throws { + let guard_ = MockPermissionGuard() + let bridge = makeBridge(permissionGuard: guard_) + let genesis = Data([0x10, 0xC1, 0x23, 0xF0] + [UInt8](repeating: 0xAB, count: 28)) + + let result = try await bridge.remotePermission( + product: testProduct, + request: .jamPeers(genesis: genesis) + ) + + #expect(result == .allowAlways) + #expect(guard_.requestedBatchedPermissions == [ + .jamPeersAccess(genesis: "0x10c123f0" + String(repeating: "ab", count: 28)) + ]) + } + // MARK: pushNotification /// `pushNotification` maps text/deeplink/scheduledAt onto the scheduler diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index e4dc765c1c..5c82b25c71 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -33,7 +33,7 @@ wasm-signing-host = [] # Test-host-only shortcuts a shipping host must not carry, such as answering # resource allocation as granted without allocating anything. test-host = [] -debug-sink = ["dep:tokio", "dep:tokio-tungstenite", "dep:base64"] +debug-sink = ["dep:tokio-tungstenite", "dep:base64"] ws-bridge = ["debug-sink", "dep:rand", "dep:libc"] [dependencies] @@ -84,12 +84,20 @@ truapi-platform = { path = "../truapi-platform", features = ["uniffi"] } futures = { version = "0.3", features = ["thread-pool"] } rand = { version = "0.8", optional = true } libc = { version = "0.2", optional = true } -tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "io-util", "time"], optional = true } +# Drives the JamPeerTransport QUIC endpoint, and the debug sink's WebSocket. +tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "io-util", "time"] } tokio-tungstenite = { version = "0.24", default-features = false, features = ["handshake"], optional = true } uniffi.workspace = true subxt = { version = "0.50.3", default-features = false, features = ["native"] } subxt-rpcs = { version = "0.50.3", default-features = false, features = ["jsonrpsee", "native"] } base64 = { version = "0.22", optional = true } +# JAMNP-S QUIC for the native JamPeerTransport; the browser dials WebTransport +# from its JavaScript session instead. +quinn = { version = "0.11", default-features = false, features = ["runtime-tokio", "rustls-ring"] } +rcgen = { version = "0.14", default-features = false, features = ["crypto", "ring"] } +ring = "0.17" +rustls = { version = "0.23", default-features = false, features = ["std", "ring"] } +x509-parser = "0.17" [target.'cfg(target_arch = "wasm32")'.dependencies] futures-timer = { version = "3", features = ["wasm-bindgen"] } diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index f824654c15..b4a226b277 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -219,6 +219,30 @@ proof contexts. Configuration keeps local activation and key derivation available offline. The core validates supported suffixes but does not automatically check that the configured suffix matches the chain. +#### JAM peer transport + +Native product runtimes (iOS, Android, CLI) serve `JamPeerTransport` (trait +111) themselves over JAMNP-S QUIC, with one endpoint per product connection. +Every `dial` first requires `RemotePermission::JamPeers { genesis }` through the +flow above. The connection asks once per genesis: concurrent dials wait for the +same prompt, a refusal stays `NotGranted` for the connection, and the answer is +persisted even when every dial waiting on it has given up. + +- A dial answers within 10 seconds, prompt included. One still waiting then + answers `Unreachable`, a cancelled one `Cancelled`, and what it would have + opened is dropped without holding one of the 8 connection slots. +- The ALPN is `jam_peer_transport::alpn(genesis)`, and the peer certificate + must carry the Ed25519 key the dial names. The P-256 key is for WebTransport + hosts and is ignored. +- The first granted dial creates the endpoint, so a refused product binds no + socket. Disposing the connection closes every peer connection, and later + calls are `Denied`. + +The browser core keeps the trait's `NotGranted` defaults, because its +JavaScript session answers trait 111 before frames reach the core. +`cargo test -p truapi-server --test live_jam_public_devnet -- --include-ignored` +dials the public JAM devnet through a product runtime. + ### The two roles Both implement the role-neutral **`ProductAuthority`** trait; each owns its diff --git a/rust/crates/truapi-server/src/core.rs b/rust/crates/truapi-server/src/core.rs index 59a18a8990..b91522b8a9 100644 --- a/rust/crates/truapi-server/src/core.rs +++ b/rust/crates/truapi-server/src/core.rs @@ -187,15 +187,20 @@ mod tests { use crate::frame::{Payload, request_ids, subscription_ids}; use crate::test_support::{StubPlatform, runtime_config, test_spawner}; + /// A dial needs the user's `JamPeers` grant for its genesis, so a refusal + /// answers `NotGranted` before anything connects. The browser core never + /// asks: its JavaScript session serves trait 111 before frames reach it. #[test] - fn a_published_product_has_no_implicit_jam_peer_transport_grant() { + fn a_dial_the_user_refuses_is_not_granted() { + let genesis = [0x35; 32]; + let platform = Arc::new(StubPlatform { + remote_permission_denied: true, + ..Default::default() + }); + let asked = platform.remote_permission_requests.clone(); let (host_config, product) = runtime_config("dotli.dot"); - let core = TrUApiCore::from_platform_with_config( - Arc::new(StubPlatform::default()), - host_config, - product, - test_spawner(), - ); + let core = + TrUApiCore::from_platform_with_config(platform, host_config, product, test_spawner()); let ids = request_ids("jam_peer_transport_dial").expect("registered peer transport"); let frame = ProtocolMessage { request_id: "p:peer".into(), @@ -205,7 +210,7 @@ mod tests { message_type: crate::frame::MESSAGE_TYPE_REQUEST, value: truapi::versioned::jam_peer_transport::HostJamPeerTransportDialRequest::V1( truapi::latest::HostJamPeerTransportDialRequest { - genesis: [0x35; 32], + genesis, ip: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1], port: 43000, ed25519: [0; 32], @@ -217,19 +222,32 @@ mod tests { }; let response = futures::executor::block_on(core.receive_from_product(&frame.encode())) .expect("registered method must answer explicitly"); + let prompted = if cfg!(target_arch = "wasm32") { + vec![] + } else { + vec![v01::RemotePermissionRequest { + permission: v01::RemotePermission::JamPeers { genesis }, + }] + }; assert_eq!( - ProtocolMessage::decode(&mut &response[..]) - .unwrap() - .payload - .value, - Err::( - truapi::CallError::Domain( - truapi::versioned::jam_peer_transport::HostJamPeerTransportDialError::V1( - truapi::latest::HostJamPeerTransportDialError::NotGranted, + ( + ProtocolMessage::decode(&mut &response[..]) + .unwrap() + .payload + .value, + asked.lock().unwrap().clone(), + ), + ( + Err::( + truapi::CallError::Domain( + truapi::versioned::jam_peer_transport::HostJamPeerTransportDialError::V1( + truapi::latest::HostJamPeerTransportDialError::NotGranted, + ), ), - ), - ) - .encode(), + ) + .encode(), + prompted, + ), ); } diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 5fade34dec..03539be0f7 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -1672,7 +1672,8 @@ impl ProductRuntime { /// Dispose this host core. Idempotent. /// /// Disposal suppresses future outgoing frames, aborts in-flight dispatch - /// futures, and cancels active subscriptions. + /// futures, cancels active subscriptions and closes the connection's JAM + /// peer connections. #[instrument(skip_all, fields(runtime.method = "product_runtime.dispose"))] pub fn dispose(&self) { // Aborting under the lock can wake code that re-enters disposal. @@ -1694,6 +1695,8 @@ impl ProductRuntime { self.admin.product_runtime.detach_chat(); self.admin.product_runtime.detach_renderer(); self.admin.product_runtime.release_open_operations(); + #[cfg(not(target_arch = "wasm32"))] + self.admin.product_runtime.close_jam_peer_transport(); self.host_subscriptions.close(); self.core.cancel_subscriptions(); } diff --git a/rust/crates/truapi-server/src/jam_peer_transport.rs b/rust/crates/truapi-server/src/jam_peer_transport.rs index 42db00cdd6..0f988d7e8d 100644 --- a/rust/crates/truapi-server/src/jam_peer_transport.rs +++ b/rust/crates/truapi-server/src/jam_peer_transport.rs @@ -12,9 +12,23 @@ //! genesis ([`alpn`]), verifies the peer certificate against the identity the //! guest named, frames messages and enforces the `JAM_PEER_TRANSPORT_MAX_*` caps //! from `truapi::latest`. +//! +//! Native product runtimes serve `JamPeerTransport` themselves over JAMNP-S +//! QUIC, one endpoint per product connection. The browser core keeps the +//! trait's `NotGranted` defaults: its JavaScript session answers trait 111 +//! over WebTransport before frames reach the core. use core::fmt; +#[cfg(not(target_arch = "wasm32"))] +mod peer_id; +#[cfg(not(target_arch = "wasm32"))] +mod quic; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) mod session; +#[cfg(not(target_arch = "wasm32"))] +mod tls; + /// JAMNP-S ALPN prefix; the suffix is the first eight hex nibbles of the genesis /// header hash. pub const ALPN_PREFIX: &str = "jamnp-s/1/"; diff --git a/rust/crates/truapi-server/src/jam_peer_transport/peer_id.rs b/rust/crates/truapi-server/src/jam_peer_transport/peer_id.rs new file mode 100644 index 0000000000..86b35dbdd3 --- /dev/null +++ b/rust/crates/truapi-server/src/jam_peer_transport/peer_id.rs @@ -0,0 +1,44 @@ +//! JAMNP-S alternative-name text form of Ed25519 peer keys. +//! +//! `N(k) = "e" ++ B(E32^-1(k), 52)`: the 256-bit key read as a little-endian +//! integer, emitted five bits at a time (least significant first) through the +//! alphabet `abcdefghijklmnopqrstuvwxyz234567`. + +const ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567"; +/// One prefix letter plus 52 base-32 digits. +const TEXT_LEN: usize = 1 + 256_usize.div_ceil(5); + +/// Alternative name of an Ed25519 peer key (`e…`). +pub(super) fn ed25519_text(key: &[u8; 32]) -> String { + let mut text = String::with_capacity(TEXT_LEN); + text.push('e'); + for bit in (0..256).step_by(5) { + let low = u16::from(key[bit / 8]); + let high = u16::from(key.get(bit / 8 + 1).copied().unwrap_or(0)); + let window = low | (high << 8); + text.push(char::from( + ALPHABET[usize::from((window >> (bit % 8)) & 0x1f)], + )); + } + text +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A PolkaJAM node logs its key as `This node is @`; the name + /// must match byte for byte or the peer's certificate check fails. + #[test] + fn matches_the_name_a_polkajam_node_logs_for_its_key() { + let key: [u8; 32] = + hex::decode("1d60a595caeb8e8a8e7e74f4364ee070264d033fed29cb5c5b0c70d4fb65de46") + .unwrap() + .try_into() + .unwrap(); + assert_eq!( + ed25519_text(&key), + "e5ayk2kkzlxdvih2pud5ndhb4qtj2ub4hnpkwmonlma4i55xm6wra" + ); + } +} diff --git a/rust/crates/truapi-server/src/jam_peer_transport/quic.rs b/rust/crates/truapi-server/src/jam_peer_transport/quic.rs new file mode 100644 index 0000000000..281d82d6fb --- /dev/null +++ b/rust/crates/truapi-server/src/jam_peer_transport/quic.rs @@ -0,0 +1,730 @@ +//! Host-terminated JAMNP-S QUIC connections with per-execution caps. +//! +//! One [`Transport`] is one execution's peer-transport authority: it owns a +//! QUIC endpoint on an ephemeral UDP port, the local identity and every +//! connection and stream the guest holds. Only `dial` and `open` wait, for the +//! QUIC handshake or the peer's stream credit, and both are bounded. The work +//! runs on the transport's own tokio runtime, so any executor may await it. +//! The guest never sees a length prefix: the host frames outgoing messages and +//! reassembles incoming ones. + +use std::collections::{HashMap, VecDeque}; +use std::net::{Ipv6Addr, SocketAddr}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use parking_lot::Mutex; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use truapi::latest; + +use super::peer_id; +use super::tls::{self, Identity, IdentityError}; + +/// Connections one execution may hold. +pub(super) const MAX_CONNECTIONS: usize = latest::JAM_PEER_TRANSPORT_MAX_CONNECTIONS as usize; +/// Streams one execution may hold per connection. +const MAX_STREAMS_PER_CONNECTION: usize = + latest::JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION as usize; +/// Largest message in either direction, without its length prefix. +const MAX_MESSAGE_BYTES: usize = latest::JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES as usize; +/// Bytes buffered per connection (outgoing not yet written plus incoming not +/// yet received by the guest) before sends fail and reads pause. +const MAX_BUFFERED_BYTES_PER_CONNECTION: usize = + latest::JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION as usize; +/// Undrained events kept for the guest; later ones are dropped. +const MAX_PENDING_EVENTS: usize = 1024; + +/// QUIC handshake deadline. +const DIAL_TIMEOUT: Duration = Duration::from_secs(5); +/// Deadline for the peer to grant stream credit on `open`. +const OPEN_TIMEOUT: Duration = Duration::from_secs(5); +/// Deadline for the kind byte of a peer-opened stream. +const ACCEPT_KIND_TIMEOUT: Duration = Duration::from_secs(5); +// Same values as a PolkaJAM node: the client keeps the connection alive. +const IDLE_TIMEOUT: Duration = Duration::from_secs(15); +const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(7); +const BACKPRESSURE_POLL: Duration = Duration::from_millis(5); +/// How long a dropped transport lets its connections finish closing. +const CLOSE_GRACE: Duration = Duration::from_secs(1); + +/// Everything `dial` needs; mirrors the TrUAPI request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct Dial { + pub(super) genesis: [u8; 32], + /// IPv6 or v4-mapped IPv6. + pub(super) ip: [u8; 16], + pub(super) port: u16, + /// Ed25519 key the peer certificate must carry. + pub(super) ed25519: [u8; 32], +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub(super) enum DialError { + #[error("peer refused the connection or presented another identity")] + Refused, + #[error("connection cap exhausted")] + Limit, + #[error("peer unreachable")] + Unreachable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub(super) enum OpenError { + #[error("connection closed or unknown")] + Closed, + #[error("stream cap exhausted")] + Limit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub(super) enum SendError { + #[error("stream closed, finished or unknown")] + Closed, + #[error("message exceeds the message cap")] + TooLarge, + #[error("connection buffer full")] + Limit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("stream or connection unknown, or fully consumed")] +pub(super) struct Closed; + +/// Result of a non-blocking `recv`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(super) struct Received { + /// One complete message, or `None` when nothing has arrived yet. + pub(super) message: Option>, + /// The peer finished its send side and every message has been delivered. + pub(super) fin: bool, + /// The peer reset the stream (or sent unframeable data); nothing more + /// will arrive. Reported after any complete messages. + pub(super) reset: bool, +} + +struct Outgoing { + bytes: Vec, + fin: bool, +} + +struct Conn { + quic: quinn::Connection, + streams: Vec, + buffered: Arc, + closed: bool, + task: JoinHandle<()>, +} + +struct Stream { + conn: u32, + inbox: VecDeque>, + fin: bool, + reset: bool, + send_open: bool, + /// `recv` reported the end of the receive side. + consumed: bool, + tx: mpsc::UnboundedSender, + reader: JoinHandle<()>, + writer: JoinHandle<()>, +} + +#[derive(Default)] +struct Inner { + conns: HashMap, + streams: HashMap, + events: VecDeque, + next_id: u32, + /// Dials between the cap check and registration; they hold a slot so + /// concurrent dials cannot exceed `MAX_CONNECTIONS`. + dialing: usize, +} + +impl Inner { + fn allocate(&mut self) -> u32 { + self.next_id += 1; + self.next_id + } + + fn push_event(&mut self, event: latest::JamPeerTransportEvent) { + if self.events.len() < MAX_PENDING_EVENTS { + self.events.push_back(event); + } + } + + /// Drop a stream, aborting both directions when `abort`; a finished + /// writer is left to flush. + fn forget_stream(&mut self, stream: u32, abort: bool) { + let Some(entry) = self.streams.remove(&stream) else { + return; + }; + if abort { + entry.reader.abort(); + entry.writer.abort(); + } + if let Some(conn) = self.conns.get_mut(&entry.conn) { + conn.streams.retain(|&id| id != stream); + conn.buffered.fetch_sub( + entry.inbox.iter().map(Vec::len).sum::(), + Ordering::AcqRel, + ); + } + } +} + +type Shared = Arc>; + +/// A reserved connection slot; released on drop unless the dial registered. +struct DialSlot<'a> { + shared: &'a Shared, + armed: bool, +} + +impl DialSlot<'_> { + fn commit(mut self, inner: &mut Inner) { + inner.dialing -= 1; + self.armed = false; + } +} + +impl Drop for DialSlot<'_> { + fn drop(&mut self) { + if self.armed { + self.shared.lock().dialing -= 1; + } + } +} + +/// Failure to bring up the endpoint. +#[derive(Debug, thiserror::Error)] +pub(super) enum TransportError { + #[error(transparent)] + Identity(#[from] IdentityError), + #[error("cannot start the transport runtime: {0}")] + Runtime(std::io::Error), + #[error("cannot bind the QUIC endpoint: {0}")] + Bind(std::io::Error), +} + +/// One execution's JAMNP-S client. +pub(super) struct Transport { + /// Taken on drop and shut down in the background, so the last owner may + /// release the transport from inside any async context. + runtime: Option, + endpoint: quinn::Endpoint, + identity: Identity, + shared: Shared, + client_transport: Arc, +} + +impl Transport { + /// Generate an identity, bind an ephemeral dual-stack UDP port and start + /// the driver runtime. + pub(super) fn new() -> Result { + let identity = Identity::generate()?; + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .thread_name("jam-peer-transport") + .enable_all() + .build() + .map_err(TransportError::Runtime)?; + let endpoint = { + let _guard = runtime.enter(); + quinn::Endpoint::client(SocketAddr::from((Ipv6Addr::UNSPECIFIED, 0))) + .map_err(TransportError::Bind)? + }; + tracing::debug!( + identity = %peer_id::ed25519_text(identity.public()), + local = ?endpoint.local_addr().ok(), + "JAMNP-S endpoint bound", + ); + let mut client_transport = quinn::TransportConfig::default(); + client_transport.max_idle_timeout(Some( + IDLE_TIMEOUT.try_into().expect("idle timeout fits a VarInt"), + )); + client_transport.keep_alive_interval(Some(KEEP_ALIVE_INTERVAL)); + Ok(Self { + runtime: Some(runtime), + endpoint, + identity, + shared: Shared::default(), + client_transport: Arc::new(client_transport), + }) + } + + fn runtime(&self) -> &tokio::runtime::Runtime { + self.runtime + .as_ref() + .expect("the runtime lives until the transport drops") + } + + /// Run a future on the driver runtime and await it from any executor, + /// waiting at most `timeout`; `None` on timeout or runtime shutdown. The + /// deadline is armed on the driver runtime: the caller may have no timer. + async fn run( + &self, + timeout: Duration, + future: impl Future + Send + 'static, + ) -> Option { + self.runtime() + .spawn(async move { tokio::time::timeout(timeout, future).await.ok() }) + .await + .ok() + .flatten() + } + + /// Connect to one peer, requiring its certificate to carry `ed25519`, + /// within [`DIAL_TIMEOUT`]. + pub(super) async fn dial(&self, dial: &Dial) -> Result { + let (slot, connecting) = self.connecting(dial)?; + self.connected(slot, self.run(DIAL_TIMEOUT, connecting).await) + } + + fn connecting(&self, dial: &Dial) -> Result<(DialSlot<'_>, quinn::Connecting), DialError> { + let slot = { + let mut inner = self.shared.lock(); + let open = inner.conns.values().filter(|conn| !conn.closed).count(); + if open + inner.dialing >= MAX_CONNECTIONS { + return Err(DialError::Limit); + } + inner.dialing += 1; + DialSlot { + shared: &self.shared, + armed: true, + } + }; + let alpn = super::alpn(&dial.genesis).into_bytes(); + let tls = tls::client_config(&self.identity, dial.ed25519, alpn) + .map_err(|_| DialError::Refused)?; + let quic_tls: quinn::crypto::rustls::QuicClientConfig = + tls.try_into().map_err(|_| DialError::Refused)?; + let mut config = quinn::ClientConfig::new(Arc::new(quic_tls)); + config.transport_config(self.client_transport.clone()); + let addr = SocketAddr::from((Ipv6Addr::from(dial.ip), dial.port)); + let name = peer_id::ed25519_text(&dial.ed25519); + // quinn spawns the connection driver from `connect_with`. + let connecting = { + let _guard = self.runtime().enter(); + self.endpoint.connect_with(config, addr, &name) + }; + let connecting = connecting.map_err(|_| DialError::Unreachable)?; + Ok((slot, connecting)) + } + + fn connected( + &self, + slot: DialSlot<'_>, + outcome: Option>, + ) -> Result { + let connection = match outcome { + Some(Ok(connection)) => connection, + // Any TLS alert (QUIC crypto error 0x100–0x1ff) means the peer + // answered but the handshake failed: a certificate that does not + // carry the pinned key, a foreign ALPN, or a rejected client. + Some(Err(quinn::ConnectionError::TransportError(error))) + if (0x100..0x200).contains(&u64::from(error.code)) => + { + return Err(DialError::Refused); + } + Some(Err( + quinn::ConnectionError::ConnectionClosed(_) + | quinn::ConnectionError::ApplicationClosed(_), + )) => return Err(DialError::Refused), + Some(Err(_)) | None => return Err(DialError::Unreachable), + }; + let mut inner = self.shared.lock(); + slot.commit(&mut inner); + let conn = inner.allocate(); + let buffered = Arc::new(AtomicUsize::new(0)); + let task = self.runtime().spawn(accept_loop( + self.shared.clone(), + conn, + connection.clone(), + buffered.clone(), + )); + inner.conns.insert( + conn, + Conn { + quic: connection, + streams: Vec::new(), + buffered, + closed: false, + task, + }, + ); + Ok(conn) + } + + /// Open a bidirectional stream and send its kind byte, within + /// [`OPEN_TIMEOUT`]. + pub(super) async fn open(&self, conn: u32, kind: u8) -> Result { + let (quic, buffered) = self.open_target(conn)?; + let opened = self + .run(OPEN_TIMEOUT, async move { quic.open_bi().await }) + .await; + self.opened(conn, kind, buffered, opened) + } + + fn open_target(&self, conn: u32) -> Result<(quinn::Connection, Arc), OpenError> { + let inner = self.shared.lock(); + let entry = inner.conns.get(&conn).ok_or(OpenError::Closed)?; + if entry.closed { + return Err(OpenError::Closed); + } + if entry.streams.len() >= MAX_STREAMS_PER_CONNECTION { + return Err(OpenError::Limit); + } + Ok((entry.quic.clone(), entry.buffered.clone())) + } + + fn opened( + &self, + conn: u32, + kind: u8, + buffered: Arc, + opened: Option>, + ) -> Result { + let (send, recv) = match opened { + Some(Ok(pair)) => pair, + Some(Err(_)) => return Err(OpenError::Closed), + None => return Err(OpenError::Limit), + }; + let mut inner = self.shared.lock(); + let Some(entry) = inner.conns.get(&conn).filter(|entry| !entry.closed) else { + return Err(OpenError::Closed); + }; + if entry.streams.len() >= MAX_STREAMS_PER_CONNECTION { + return Err(OpenError::Limit); + } + // The writer subtracts every byte it flushes, so count the kind byte. + buffered.fetch_add(1, Ordering::AcqRel); + let stream = register_stream( + self.runtime().handle(), + &self.shared, + &mut inner, + conn, + send, + recv, + buffered, + ); + let entry = inner.streams.get(&stream).expect("just registered"); + let _ = entry.tx.send(Outgoing { + bytes: vec![kind], + fin: false, + }); + Ok(stream) + } + + /// Queue one framed message; `fin` finishes the send side after it. + pub(super) fn send(&self, stream: u32, message: &[u8], fin: bool) -> Result<(), SendError> { + if message.len() > MAX_MESSAGE_BYTES { + return Err(SendError::TooLarge); + } + let mut inner = self.shared.lock(); + let entry = inner.streams.get(&stream).ok_or(SendError::Closed)?; + if !entry.send_open { + return Err(SendError::Closed); + } + let buffered = inner + .conns + .get(&entry.conn) + .map(|conn| conn.buffered.clone()) + .ok_or(SendError::Closed)?; + let framed_len = message.len() + 4; + if buffered.load(Ordering::Acquire) + framed_len > MAX_BUFFERED_BYTES_PER_CONNECTION { + return Err(SendError::Limit); + } + buffered.fetch_add(framed_len, Ordering::AcqRel); + let mut bytes = Vec::with_capacity(framed_len); + bytes.extend_from_slice(&(message.len() as u32).to_le_bytes()); + bytes.extend_from_slice(message); + let entry = inner.streams.get_mut(&stream).expect("checked above"); + if fin { + entry.send_open = false; + } + entry + .tx + .send(Outgoing { bytes, fin }) + .map_err(|_| SendError::Closed) + } + + /// Pop one complete message if any; report fin or reset once drained. + /// + /// The call that reports the end with no message consumes the receive + /// side: later calls are [`Closed`], and a stream whose send side is also + /// done is forgotten. + pub(super) fn recv(&self, stream: u32, max: usize) -> Result { + let mut inner = self.shared.lock(); + let entry = inner + .streams + .get_mut(&stream) + .filter(|entry| !entry.consumed) + .ok_or(Closed)?; + let mut released = 0; + if entry.inbox.front().is_some_and(|front| front.len() > max) { + // The guest cannot take this message; the stream cannot progress. + released = entry.inbox.drain(..).map(|message| message.len()).sum(); + entry.reset = true; + entry.reader.abort(); + } + let message = entry.inbox.pop_front(); + let drained = entry.inbox.is_empty(); + let received = Received { + fin: drained && entry.fin, + reset: drained && entry.reset, + message, + }; + entry.consumed = received.message.is_none() && (received.fin || received.reset); + let forget = entry.consumed && (received.reset || !entry.send_open); + let conn = entry.conn; + released += received.message.as_ref().map_or(0, Vec::len); + if let Some(conn) = inner.conns.get(&conn) { + conn.buffered.fetch_sub(released, Ordering::AcqRel); + } + if forget { + inner.forget_stream(stream, received.reset); + } + Ok(received) + } + + /// Abort both directions and forget the stream. + pub(super) fn reset(&self, stream: u32) -> Result<(), Closed> { + let mut inner = self.shared.lock(); + if !inner.streams.contains_key(&stream) { + return Err(Closed); + } + inner.forget_stream(stream, true); + Ok(()) + } + + /// Close a connection and every stream on it. No `ConnClosed` event is + /// queued for a guest-initiated close. + pub(super) fn close(&self, conn: u32) -> Result<(), Closed> { + let mut inner = self.shared.lock(); + let entry = inner.conns.remove(&conn).ok_or(Closed)?; + entry.task.abort(); + for stream in entry.streams { + if let Some(stream) = inner.streams.remove(&stream) { + stream.reader.abort(); + stream.writer.abort(); + } + } + entry.quic.close(0u32.into(), b""); + Ok(()) + } + + /// Drain pending events in arrival order. + pub(super) fn events(&self) -> Vec { + self.shared.lock().events.drain(..).collect() + } + + /// Close every connection and the endpoint. + pub(super) fn shutdown(&self) { + let conns: Vec = self.shared.lock().conns.keys().copied().collect(); + for conn in conns { + let _ = self.close(conn); + } + self.endpoint.close(0u32.into(), b""); + } +} + +impl Drop for Transport { + fn drop(&mut self) { + self.shutdown(); + let Some(runtime) = self.runtime.take() else { + return; + }; + // A validator keeps a connection that was never closed until it idles + // out, and may refuse this address's next dials meanwhile, so the + // driver gets to send the close frames. It runs on its own thread: + // dropping a runtime blocks on its workers, which panics inside + // another runtime. + let endpoint = self.endpoint.clone(); + let (handoff, handed) = std::sync::mpsc::channel::(); + let closer = std::thread::Builder::new() + .name("jam-peer-transport-close".into()) + .spawn(move || { + if let Ok(runtime) = handed.recv() { + runtime.block_on(async { + let _ = tokio::time::timeout(CLOSE_GRACE, endpoint.wait_idle()).await; + }); + } + }); + let unsent = match closer { + Ok(_) => handoff.send(runtime).err().map(|unsent| unsent.0), + Err(_) => Some(runtime), + }; + if let Some(runtime) = unsent { + runtime.shutdown_background(); + } + } +} + +fn register_stream( + handle: &tokio::runtime::Handle, + shared: &Shared, + inner: &mut Inner, + conn: u32, + send: quinn::SendStream, + recv: quinn::RecvStream, + buffered: Arc, +) -> u32 { + let stream = inner.allocate(); + let (tx, rx) = mpsc::unbounded_channel(); + let reader = handle.spawn(read_loop(shared.clone(), stream, recv, buffered.clone())); + let writer = handle.spawn(write_loop(shared.clone(), stream, send, rx, buffered)); + inner.streams.insert( + stream, + Stream { + conn, + inbox: VecDeque::new(), + fin: false, + reset: false, + send_open: true, + consumed: false, + tx, + reader, + writer, + }, + ); + inner + .conns + .get_mut(&conn) + .expect("caller checked the connection") + .streams + .push(stream); + stream +} + +async fn accept_loop( + shared: Shared, + conn: u32, + quic: quinn::Connection, + buffered: Arc, +) { + loop { + match quic.accept_bi().await { + Ok((send, mut recv)) => { + let mut kind = [0u8; 1]; + let read = tokio::time::timeout(ACCEPT_KIND_TIMEOUT, recv.read_exact(&mut kind)); + if !matches!(read.await, Ok(Ok(()))) { + // Dropping both halves resets the stream. + continue; + } + let mut inner = shared.lock(); + let Some(entry) = inner.conns.get(&conn).filter(|entry| !entry.closed) else { + return; + }; + if entry.streams.len() >= MAX_STREAMS_PER_CONNECTION { + continue; + } + let stream = register_stream( + &tokio::runtime::Handle::current(), + &shared, + &mut inner, + conn, + send, + recv, + buffered.clone(), + ); + inner.push_event(latest::JamPeerTransportEvent::Accepted { + conn, + stream, + kind: kind[0], + }); + } + Err(error) => { + tracing::debug!("JAM peer connection {conn} closed: {error}"); + let mut inner = shared.lock(); + if let Some(entry) = inner.conns.get_mut(&conn) { + entry.closed = true; + inner.push_event(latest::JamPeerTransportEvent::ConnClosed { conn }); + } + return; + } + } + } +} + +async fn read_loop( + shared: Shared, + stream: u32, + mut recv: quinn::RecvStream, + buffered: Arc, +) { + use quinn::ReadExactError; + loop { + while buffered.load(Ordering::Acquire) >= MAX_BUFFERED_BYTES_PER_CONNECTION { + tokio::time::sleep(BACKPRESSURE_POLL).await; + } + let mut len = [0u8; 4]; + let clean_fin = match recv.read_exact(&mut len).await { + Ok(()) => None, + Err(ReadExactError::FinishedEarly(0)) => Some(true), + Err(_) => Some(false), + }; + if let Some(clean) = clean_fin { + finish_read(&shared, stream, clean); + return; + } + let len = u32::from_le_bytes(len) as usize; + if len > MAX_MESSAGE_BYTES { + let _ = recv.stop(0u32.into()); + finish_read(&shared, stream, false); + return; + } + let mut message = vec![0u8; len]; + if recv.read_exact(&mut message).await.is_err() { + finish_read(&shared, stream, false); + return; + } + buffered.fetch_add(len, Ordering::AcqRel); + let mut inner = shared.lock(); + match inner.streams.get_mut(&stream) { + Some(entry) => entry.inbox.push_back(message), + None => return, + } + } +} + +fn finish_read(shared: &Shared, stream: u32, clean: bool) { + let mut inner = shared.lock(); + if let Some(entry) = inner.streams.get_mut(&stream) { + if clean { + entry.fin = true; + inner.push_event(latest::JamPeerTransportEvent::StreamFin { stream }); + } else { + entry.reset = true; + } + } +} + +async fn write_loop( + shared: Shared, + stream: u32, + mut send: quinn::SendStream, + mut rx: mpsc::UnboundedReceiver, + buffered: Arc, +) { + while let Some(outgoing) = rx.recv().await { + let written = send.write_all(&outgoing.bytes).await; + buffered.fetch_sub(outgoing.bytes.len(), Ordering::AcqRel); + if written.is_err() { + if let Some(entry) = shared.lock().streams.get_mut(&stream) { + entry.send_open = false; + } + return; + } + if outgoing.fin { + let _ = send.finish(); + // Keep the handle until the peer acknowledges or the stream is + // dropped by `reset`/`close`; dropping early would not reset a + // finished stream but would forfeit the stopped notification. + let _ = send.stopped().await; + return; + } + } + // Channel closed: the stream was reset or its connection closed. Dropping + // an unfinished SendStream resets it. +} diff --git a/rust/crates/truapi-server/src/jam_peer_transport/session.rs b/rust/crates/truapi-server/src/jam_peer_transport/session.rs new file mode 100644 index 0000000000..0499608814 --- /dev/null +++ b/rust/crates/truapi-server/src/jam_peer_transport/session.rs @@ -0,0 +1,372 @@ +//! One product connection's `JamPeerTransport` over JAMNP-S QUIC. +//! +//! `dial` requires `RemotePermission::JamPeers { genesis }`: the caller hands +//! in the permission check (stored decision, else prompt, then persist), and +//! the session runs it once per genesis for this connection and keeps the +//! answer, so concurrent dials wait for one prompt and a refusal stays +//! `NotGranted` without asking again. The check runs on the runtime spawner, +//! so its answer is kept even when every dial waiting on it has given up. +//! +//! A dial answers within [`DIAL_DEADLINE`], prompt included: one still waiting +//! then answers `Unreachable`, a cancelled one `Cancelled`, and whatever it +//! would have opened is dropped without holding a connection slot. The QUIC +//! endpoint is created by the first granted dial, so a connection that never +//! dials, or is refused, binds no socket. After [`JamPeerSession::revoke`] +//! every call is `Denied`. + +use core::time::Duration; +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use futures::{FutureExt, pin_mut}; +use parking_lot::Mutex; +use tokio::sync::watch; +use truapi::versioned::jam_peer_transport as wire; +use truapi::{CallContext, CallError, latest}; + +use super::quic::{self, Dial, Transport}; +use crate::subscription::Spawner; + +/// Bound on one `dial`, from its arrival to its reply, the permission prompt +/// included. A guest that waits at least this long for a dial reply never +/// misses one, and anything a dial would open after it is closed instead. +const DIAL_DEADLINE: Duration = Duration::from_secs(10); + +/// Largest reply frame a native PolkaVM runtime accepts +/// (`polkavm_host_runtime::MAX_HOST_FRAME_BYTES`); a `recv` reply carries its +/// message inside one, next to the request id and the SCALE envelope. +const MAX_REPLY_FRAME_BYTES: usize = 1024 * 1024; +/// Room left in a `recv` reply frame for everything but the message. +const REPLY_ENVELOPE_BYTES: usize = 128; + +/// The answer to `RemotePermission::JamPeers` for one genesis. +type Decision = Result<(), CallError>; + +/// One product connection's peer transport. +pub(crate) struct JamPeerSession { + /// Created by the first granted dial; `None` again after [`Self::revoke`]. + transport: Mutex>>, + /// Permission answers for this connection, by genesis. + decisions: Mutex>>>, + dial_deadline: Duration, + revoked: AtomicBool, +} + +fn dial_error( + error: latest::HostJamPeerTransportDialError, +) -> CallError { + CallError::Domain(wire::HostJamPeerTransportDialError::V1(error)) +} + +impl JamPeerSession { + /// A session with no endpoint and no permission answers yet. + pub(crate) fn new() -> Self { + Self { + transport: Mutex::new(None), + decisions: Mutex::new(HashMap::new()), + dial_deadline: DIAL_DEADLINE, + revoked: AtomicBool::new(false), + } + } + + /// Close every connection; every later call, including one still waiting + /// for a handshake or a permission prompt, is `Denied`. + pub(crate) fn revoke(&self) { + let transport = { + let mut transport = self.transport.lock(); + self.revoked.store(true, Ordering::Release); + transport.take() + }; + if let Some(transport) = transport { + transport.shutdown(); + } + } + + fn live(&self) -> Result<(), CallError> { + if self.revoked.load(Ordering::Acquire) { + Err(CallError::Denied) + } else { + Ok(()) + } + } + + /// The endpoint, if a granted dial has created it. + fn existing(&self) -> Option> { + self.transport.lock().clone() + } + + /// The endpoint, created on first use. Never created after a revoke. + fn endpoint(&self) -> Result, CallError> { + let mut transport = self.transport.lock(); + self.live()?; + if let Some(transport) = &*transport { + return Ok(transport.clone()); + } + let created = Arc::new(Transport::new().map_err(|error| CallError::HostFailure { + reason: format!("JAM peer transport unavailable: {error}"), + })?); + *transport = Some(created.clone()); + Ok(created) + } + + /// `JamPeers { genesis }` for this connection, checked once. + async fn permitted( + &self, + genesis: [u8; 32], + authorize: impl FnOnce() -> F, + spawner: &Spawner, + ) -> Decision + where + F: Future + Send + 'static, + { + let (mut decision, first) = { + let mut decisions = self.decisions.lock(); + match decisions.get(&genesis) { + Some(decision) => (decision.clone(), None), + None => { + let (answer, decision) = watch::channel(None); + decisions.insert(genesis, decision.clone()); + (decision, Some(answer)) + } + } + }; + if let Some(answer) = first { + let check = authorize(); + spawner(Box::pin(async move { + let _ = answer.send(Some(check.await)); + })); + } + match decision.wait_for(Option::is_some).await { + Ok(answer) => answer.clone().expect("waited for an answer"), + // A check that ended without answering refuses. + Err(_) => Err(dial_error( + latest::HostJamPeerTransportDialError::NotGranted, + )), + } + } + + async fn dial_granted( + &self, + request: latest::HostJamPeerTransportDialRequest, + authorize: impl FnOnce() -> F, + spawner: &Spawner, + ) -> Result< + wire::HostJamPeerTransportDialResponse, + CallError, + > + where + F: Future + Send + 'static, + { + self.permitted(request.genesis, authorize, spawner).await?; + let transport = self.endpoint()?; + // Native hosts speak JAMNP-S QUIC; the P-256 id is for WebTransport hosts. + let conn = transport + .dial(&Dial { + genesis: request.genesis, + ip: request.ip, + port: request.port, + ed25519: request.ed25519, + }) + .await + .map_err(|error| { + dial_error(match error { + quic::DialError::Refused => latest::HostJamPeerTransportDialError::Refused, + quic::DialError::Limit => latest::HostJamPeerTransportDialError::Limit, + quic::DialError::Unreachable => { + latest::HostJamPeerTransportDialError::Unreachable + } + }) + })?; + if self.revoked.load(Ordering::Acquire) { + // Revoked while the handshake ran: the connection must not outlive it. + let _ = transport.close(conn); + return Err(CallError::Denied); + } + Ok(wire::HostJamPeerTransportDialResponse::V1( + latest::HostJamPeerTransportDialResponse { conn }, + )) + } + + /// Dial one peer once `authorize` grants its genesis. `authorize` runs at + /// most once per genesis for this connection. + pub(crate) async fn dial( + &self, + cx: &CallContext, + request: wire::HostJamPeerTransportDialRequest, + authorize: impl FnOnce() -> F, + spawner: &Spawner, + ) -> Result< + wire::HostJamPeerTransportDialResponse, + CallError, + > + where + F: Future + Send + 'static, + { + self.live()?; + let wire::HostJamPeerTransportDialRequest::V1(request) = request; + // Dropping `granted` early drops its pending handshake, whose + // connection slot is released with it. + let granted = self.dial_granted(request, authorize, spawner).fuse(); + let cancelled = cx.cancel().cancelled().fuse(); + let deadline = futures_timer::Delay::new(self.dial_deadline).fuse(); + pin_mut!(granted, cancelled, deadline); + futures::select_biased! { + reply = granted => reply, + _ = cancelled => Err(CallError::Cancelled), + () = deadline => Err(dial_error(latest::HostJamPeerTransportDialError::Unreachable)), + } + } + + /// Open a stream on a connection a granted dial opened. + pub(crate) async fn open( + &self, + request: wire::HostJamPeerTransportOpenRequest, + ) -> Result< + wire::HostJamPeerTransportOpenResponse, + CallError, + > { + self.live()?; + let wire::HostJamPeerTransportOpenRequest::V1(request) = request; + let closed = || { + CallError::Domain(wire::HostJamPeerTransportOpenError::V1( + latest::HostJamPeerTransportOpenError::Closed, + )) + }; + let transport = self.existing().ok_or_else(closed)?; + let stream = + transport + .open(request.conn, request.kind) + .await + .map_err(|error| match error { + quic::OpenError::Closed => closed(), + quic::OpenError::Limit => { + CallError::Domain(wire::HostJamPeerTransportOpenError::V1( + latest::HostJamPeerTransportOpenError::Limit, + )) + } + })?; + if self.revoked.load(Ordering::Acquire) { + let _ = transport.reset(stream); + return Err(CallError::Denied); + } + Ok(wire::HostJamPeerTransportOpenResponse::V1( + latest::HostJamPeerTransportOpenResponse { stream }, + )) + } + + /// Queue one message on a stream. + pub(crate) fn send( + &self, + request: wire::HostJamPeerTransportSendRequest, + ) -> Result< + wire::HostJamPeerTransportSendResponse, + CallError, + > { + self.live()?; + let wire::HostJamPeerTransportSendRequest::V1(request) = request; + let domain = |error| CallError::Domain(wire::HostJamPeerTransportSendError::V1(error)); + self.existing() + .ok_or(quic::SendError::Closed) + .and_then(|transport| transport.send(request.stream, &request.message, request.fin)) + .map_err(|error| { + domain(match error { + quic::SendError::Closed => latest::HostJamPeerTransportSendError::Closed, + quic::SendError::TooLarge => latest::HostJamPeerTransportSendError::TooLarge, + quic::SendError::Limit => latest::HostJamPeerTransportSendError::Limit, + }) + })?; + Ok(wire::HostJamPeerTransportSendResponse::V1) + } + + /// Poll one message from a stream. + pub(crate) fn recv( + &self, + request: wire::HostJamPeerTransportRecvRequest, + ) -> Result< + wire::HostJamPeerTransportRecvResponse, + CallError, + > { + self.live()?; + let wire::HostJamPeerTransportRecvRequest::V1(request) = request; + let max = (request.max as usize).min(MAX_REPLY_FRAME_BYTES - REPLY_ENVELOPE_BYTES); + let received = self + .existing() + .ok_or(quic::Closed) + .and_then(|transport| transport.recv(request.stream, max)) + .map_err(|quic::Closed| { + CallError::Domain(wire::HostJamPeerTransportRecvError::V1( + latest::HostJamPeerTransportRecvError::Closed, + )) + })?; + Ok(wire::HostJamPeerTransportRecvResponse::V1( + latest::HostJamPeerTransportRecvResponse { + message: received.message, + fin: received.fin, + reset: received.reset, + }, + )) + } + + /// Abort a stream in both directions. + pub(crate) fn reset( + &self, + request: wire::HostJamPeerTransportResetRequest, + ) -> Result< + wire::HostJamPeerTransportResetResponse, + CallError, + > { + self.live()?; + let wire::HostJamPeerTransportResetRequest::V1(request) = request; + self.existing() + .ok_or(quic::Closed) + .and_then(|transport| transport.reset(request.stream)) + .map_err(|quic::Closed| { + CallError::Domain(wire::HostJamPeerTransportResetError::V1( + latest::HostJamPeerTransportResetError::Closed, + )) + })?; + Ok(wire::HostJamPeerTransportResetResponse::V1) + } + + /// Close a connection and every stream on it. + pub(crate) fn close( + &self, + request: wire::HostJamPeerTransportCloseRequest, + ) -> Result< + wire::HostJamPeerTransportCloseResponse, + CallError, + > { + self.live()?; + let wire::HostJamPeerTransportCloseRequest::V1(request) = request; + self.existing() + .ok_or(quic::Closed) + .and_then(|transport| transport.close(request.conn)) + .map_err(|quic::Closed| { + CallError::Domain(wire::HostJamPeerTransportCloseError::V1( + latest::HostJamPeerTransportCloseError::Closed, + )) + })?; + Ok(wire::HostJamPeerTransportCloseResponse::V1) + } + + /// Drain pending events. + pub(crate) fn events( + &self, + ) -> Result< + wire::HostJamPeerTransportEventsResponse, + CallError, + > { + self.live()?; + let events = self + .existing() + .map(|transport| transport.events()) + .unwrap_or_default(); + Ok(wire::HostJamPeerTransportEventsResponse::V1( + latest::HostJamPeerTransportEventsResponse { events }, + )) + } +} + +#[cfg(test)] +mod tests; diff --git a/rust/crates/truapi-server/src/jam_peer_transport/session/tests.rs b/rust/crates/truapi-server/src/jam_peer_transport/session/tests.rs new file mode 100644 index 0000000000..f4065427cc --- /dev/null +++ b/rust/crates/truapi-server/src/jam_peer_transport/session/tests.rs @@ -0,0 +1,447 @@ +use std::net::Ipv4Addr; +use std::sync::atomic::AtomicUsize; + +use super::super::tls::Identity; +use super::*; +use crate::test_support::test_spawner; + +const GENESIS: [u8; 32] = [0x35; 32]; + +fn cx() -> CallContext { + CallContext::with_request_id("t".into()) +} + +fn not_granted() -> Decision { + Err(dial_error( + latest::HostJamPeerTransportDialError::NotGranted, + )) +} + +/// What the runtime's permission check answers: `GENESIS` only, counting how +/// often it is asked. +fn grant_genesis( + asked: &Arc, + genesis: [u8; 32], +) -> impl Future + Send + 'static { + asked.fetch_add(1, Ordering::SeqCst); + async move { + if genesis == GENESIS { + Ok(()) + } else { + not_granted() + } + } +} + +/// A permission check that answers once `decision` fires. +fn prompt( + asked: &Arc, + decision: &watch::Receiver, +) -> impl Future + Send + 'static { + asked.fetch_add(1, Ordering::SeqCst); + let mut decision = decision.clone(); + async move { + let _ = decision.changed().await; + let granted = *decision.borrow(); + if granted { Ok(()) } else { not_granted() } + } +} + +fn session_with_deadline(dial_deadline: Duration) -> JamPeerSession { + JamPeerSession { + dial_deadline, + ..JamPeerSession::new() + } +} + +fn dial_request( + genesis: [u8; 32], + port: u16, + ed25519: [u8; 32], +) -> wire::HostJamPeerTransportDialRequest { + wire::HostJamPeerTransportDialRequest::V1(latest::HostJamPeerTransportDialRequest { + genesis, + ip: Ipv4Addr::LOCALHOST.to_ipv6_mapped().octets(), + port, + ed25519, + p256: None, + }) +} + +fn domain(error: CallError) -> Option { + match error { + CallError::Domain(error) => Some(error), + _ => None, + } +} + +fn dial_failure( + error: CallError, +) -> Option { + domain(error).map(|wire::HostJamPeerTransportDialError::V1(error)| error) +} + +/// A bound socket that never answers keeps every dial in its handshake. +fn silent_port() -> (std::net::UdpSocket, u16) { + let silent = std::net::UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = silent.local_addr().unwrap().port(); + (silent, port) +} + +/// A JAMNP-S peer on loopback: presents a certificate for `identity`, then +/// answers the first message of the first stream with `reply` and finishes. +fn peer(identity: &Identity, reply: &'static [u8]) -> (quinn::Endpoint, u16) { + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let mut tls = rustls::ServerConfig::builder_with_provider(provider) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .with_no_client_auth() + .with_single_cert(identity.cert_chain(), identity.private_key()) + .unwrap(); + tls.alpn_protocols = vec![super::super::alpn(&GENESIS).into_bytes()]; + let crypto = quinn::crypto::rustls::QuicServerConfig::try_from(tls).unwrap(); + let endpoint = quinn::Endpoint::server( + quinn::ServerConfig::with_crypto(Arc::new(crypto)), + (Ipv4Addr::LOCALHOST, 0).into(), + ) + .unwrap(); + let port = endpoint.local_addr().unwrap().port(); + let server = endpoint.clone(); + tokio::spawn(async move { + let connection = server.accept().await.unwrap().await.unwrap(); + let (mut send, mut recv) = connection.accept_bi().await.unwrap(); + let mut kind = [0u8; 1]; + recv.read_exact(&mut kind).await.unwrap(); + let mut length = [0u8; 4]; + recv.read_exact(&mut length).await.unwrap(); + let mut message = vec![0u8; u32::from_le_bytes(length) as usize]; + recv.read_exact(&mut message).await.unwrap(); + assert_eq!(kind, [0], "the host sends the stream kind first"); + assert_eq!(message, b"hello", "the host frames the message"); + send.write_all(&(reply.len() as u32).to_le_bytes()) + .await + .unwrap(); + send.write_all(reply).await.unwrap(); + send.finish().unwrap(); + connection.closed().await; + }); + (endpoint, port) +} + +#[test] +fn a_granted_dial_frames_messages_outside_tokio_and_revoke_denies_everything() { + // The native runtime drives host traits on a futures executor: no tokio + // timer or reactor exists on the calling thread. + let server = tokio::runtime::Runtime::new().unwrap(); + let identity = Identity::generate().unwrap(); + let (_peer, port) = { + let _context = server.enter(); + peer(&identity, b"welcome") + }; + let asked = Arc::new(AtomicUsize::new(0)); + let spawner = test_spawner(); + futures::executor::block_on(async { + let session = JamPeerSession::new(); + let wire::HostJamPeerTransportDialResponse::V1(latest::HostJamPeerTransportDialResponse { + conn, + }) = session + .dial( + &cx(), + dial_request(GENESIS, port, *identity.public()), + || grant_genesis(&asked, GENESIS), + &spawner, + ) + .await + .unwrap(); + let wire::HostJamPeerTransportOpenResponse::V1(latest::HostJamPeerTransportOpenResponse { + stream, + }) = session + .open(wire::HostJamPeerTransportOpenRequest::V1( + latest::HostJamPeerTransportOpenRequest { conn, kind: 0 }, + )) + .await + .unwrap(); + session + .send(wire::HostJamPeerTransportSendRequest::V1( + latest::HostJamPeerTransportSendRequest { + stream, + message: b"hello".to_vec(), + fin: false, + }, + )) + .unwrap(); + + // Poll until the peer's reply and its finish have been reported. The + // finish may ride on the last message or come alone after it. + let recv = + wire::HostJamPeerTransportRecvRequest::V1(latest::HostJamPeerTransportRecvRequest { + stream, + max: 1024, + }); + let mut messages = Vec::new(); + let mut end = None; + for _ in 0..500 { + let wire::HostJamPeerTransportRecvResponse::V1(response) = + session.recv(recv.clone()).unwrap(); + match response.message { + Some(message) => messages.push(message), + None if response.fin || response.reset => { + end = Some(response); + break; + } + None => std::thread::sleep(Duration::from_millis(10)), + } + } + assert_eq!( + (messages, end), + ( + vec![b"welcome".to_vec()], + Some(latest::HostJamPeerTransportRecvResponse { + message: None, + fin: true, + reset: false, + }), + ), + "the host strips the length, then reports the finish once drained", + ); + assert_eq!( + session.recv(recv.clone()).map_err(domain), + Err(Some(wire::HostJamPeerTransportRecvError::V1( + latest::HostJamPeerTransportRecvError::Closed + ))), + "a fully consumed receive side is closed", + ); + + session.revoke(); + let denied = [ + session + .dial( + &cx(), + dial_request(GENESIS, port, *identity.public()), + || grant_genesis(&asked, GENESIS), + &spawner, + ) + .await + .is_err_and(|error| matches!(error, CallError::Denied)), + session + .recv(recv.clone()) + .is_err_and(|error| matches!(error, CallError::Denied)), + session + .events() + .is_err_and(|error| matches!(error, CallError::Denied)), + ]; + assert_eq!(denied, [true; 3], "a revoked session denies every call"); + }); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_refused_genesis_binds_nothing_and_a_foreign_key_is_refused() { + let identity = Identity::generate().unwrap(); + let (_peer, port) = peer(&identity, b"unused"); + let asked = Arc::new(AtomicUsize::new(0)); + let spawner = test_spawner(); + let session = JamPeerSession::new(); + + let foreign = session + .dial( + &cx(), + dial_request([0x11; 32], port, *identity.public()), + || grant_genesis(&asked, [0x11; 32]), + &spawner, + ) + .await + .unwrap_err(); + assert_eq!( + (dial_failure(foreign), session.existing().is_none()), + ( + Some(latest::HostJamPeerTransportDialError::NotGranted), + true + ), + "a refused dial never creates the QUIC endpoint, so no packet leaves", + ); + + let impostor = Identity::generate().unwrap(); + let refused = session + .dial( + &cx(), + dial_request(GENESIS, port, *impostor.public()), + || grant_genesis(&asked, GENESIS), + &spawner, + ) + .await + .unwrap_err(); + assert_eq!( + dial_failure(refused), + Some(latest::HostJamPeerTransportDialError::Refused), + "a certificate for another key is refused" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn concurrent_dials_never_exceed_the_connection_cap_and_ask_once() { + let asked = Arc::new(AtomicUsize::new(0)); + let session = Arc::new(JamPeerSession::new()); + let (silent, port) = silent_port(); + let dials = (0..quic::MAX_CONNECTIONS + 4).map(|_| { + let session = session.clone(); + let asked = asked.clone(); + tokio::spawn(async move { + session + .dial( + &cx(), + dial_request(GENESIS, port, [7; 32]), + || grant_genesis(&asked, GENESIS), + &test_spawner(), + ) + .await + .unwrap_err() + }) + }); + let (mut unreachable, mut limited) = (0, 0); + for dial in dials.collect::>() { + match dial_failure(dial.await.unwrap()) { + Some(latest::HostJamPeerTransportDialError::Unreachable) => unreachable += 1, + Some(latest::HostJamPeerTransportDialError::Limit) => limited += 1, + other => panic!("unexpected dial result {other:?}"), + } + } + assert_eq!( + (unreachable, limited), + (quic::MAX_CONNECTIONS, 4), + "at most the cap dials at once; every dial beyond it is refused immediately", + ); + // Released slots are reusable. + let again = session + .dial( + &cx(), + dial_request(GENESIS, port, [7; 32]), + || grant_genesis(&asked, GENESIS), + &test_spawner(), + ) + .await + .unwrap_err(); + assert_eq!( + (dial_failure(again), asked.load(Ordering::SeqCst)), + (Some(latest::HostJamPeerTransportDialError::Unreachable), 1), + "concurrent dials of one genesis ask once", + ); + drop(silent); + // Dropping the session here, inside a runtime worker, must not panic. +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_prompt_outlasting_the_deadline_is_unreachable_and_remembered() { + let identity = Identity::generate().unwrap(); + let (_peer, port) = peer(&identity, b"unused"); + let asked = Arc::new(AtomicUsize::new(0)); + let (answer, decision) = watch::channel(false); + let spawner = test_spawner(); + let session = session_with_deadline(Duration::from_millis(50)); + + let late = session + .dial( + &cx(), + dial_request(GENESIS, port, *identity.public()), + || prompt(&asked, &decision), + &spawner, + ) + .await + .unwrap_err(); + assert_eq!( + dial_failure(late), + Some(latest::HostJamPeerTransportDialError::Unreachable) + ); + // The user answers after the guest stopped waiting: nothing was opened. + answer.send(true).unwrap(); + tokio::time::sleep(Duration::from_millis(20)).await; + assert!(session.existing().is_none()); + + session + .dial( + &cx(), + dial_request(GENESIS, port, *identity.public()), + || prompt(&asked, &decision), + &spawner, + ) + .await + .expect("the retry reuses the remembered grant"); + assert_eq!( + asked.load(Ordering::SeqCst), + 1, + "the retry does not ask again" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_cancel_during_the_prompt_is_cancelled_and_opens_nothing() { + let asked = Arc::new(AtomicUsize::new(0)); + let (answer, decision) = watch::channel(false); + let spawner = test_spawner(); + let session = JamPeerSession::new(); + let cx = cx(); + let cancel = cx.cancel().clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + cancel.cancel(); + }); + let (_silent, port) = silent_port(); + + let error = session + .dial( + &cx, + dial_request(GENESIS, port, [7; 32]), + || prompt(&asked, &decision), + &spawner, + ) + .await + .unwrap_err(); + assert!(matches!(error, CallError::Cancelled)); + answer.send(true).unwrap(); + tokio::time::sleep(Duration::from_millis(20)).await; + + // Every slot is free: the cancelled dial left no handshake behind. + let dials = (0..quic::MAX_CONNECTIONS).map(|_| { + session.dial_granted( + latest::HostJamPeerTransportDialRequest { + genesis: GENESIS, + ip: Ipv4Addr::LOCALHOST.to_ipv6_mapped().octets(), + port, + ed25519: [7; 32], + p256: None, + }, + || prompt(&asked, &decision), + &spawner, + ) + }); + for result in futures::future::join_all(dials).await { + assert_eq!( + dial_failure(result.unwrap_err()), + Some(latest::HostJamPeerTransportDialError::Unreachable) + ); + } + assert_eq!(asked.load(Ordering::SeqCst), 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_handshake_outlasting_the_deadline_frees_its_slot() { + let asked = Arc::new(AtomicUsize::new(0)); + let spawner = test_spawner(); + let session = session_with_deadline(Duration::from_millis(50)); + let (_silent, port) = silent_port(); + for _ in 0..quic::MAX_CONNECTIONS + 2 { + let error = session + .dial( + &cx(), + dial_request(GENESIS, port, [7; 32]), + || grant_genesis(&asked, GENESIS), + &spawner, + ) + .await + .unwrap_err(); + assert_eq!( + dial_failure(error), + Some(latest::HostJamPeerTransportDialError::Unreachable), + "an expired dial never holds a slot, so none hits Limit" + ); + } +} diff --git a/rust/crates/truapi-server/src/jam_peer_transport/tls.rs b/rust/crates/truapi-server/src/jam_peer_transport/tls.rs new file mode 100644 index 0000000000..083c4b92ba --- /dev/null +++ b/rust/crates/truapi-server/src/jam_peer_transport/tls.rs @@ -0,0 +1,230 @@ +//! JAMNP-S TLS: a self-signed Ed25519 client certificate whose single DNS +//! alternative name is the key's text form, and a server verifier that pins +//! the peer's Ed25519 key. Certificate signatures are not checked (the spec +//! neither requires nor forbids self-signing); the TLS 1.3 handshake signature +//! is verified against the pinned key. + +use std::sync::Arc; + +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName, UnixTime}; +use rustls::{DigitallySignedStruct, SignatureScheme}; +use x509_parser::der_parser::asn1_rs::oid; +use x509_parser::prelude::*; + +use super::peer_id; + +/// Failure to build the local identity. +#[derive(Debug, thiserror::Error)] +#[error("cannot build the local JAMNP-S identity: {0}")] +pub(super) struct IdentityError(#[from] rcgen::Error); + +/// Local Ed25519 identity with its self-signed certificate. +pub(super) struct Identity { + public: [u8; 32], + cert: CertificateDer<'static>, + key: PrivatePkcs8KeyDer<'static>, +} + +impl Identity { + /// A fresh identity; every peer accepts any well-formed key. + pub(super) fn generate() -> Result { + let key_pair = rcgen::KeyPair::generate_for(&rcgen::PKCS_ED25519)?; + let public: [u8; 32] = key_pair + .public_key_raw() + .try_into() + .expect("Ed25519 public keys are 32 bytes"); + let mut params = rcgen::CertificateParams::new(vec![peer_id::ed25519_text(&public)])?; + let mut name = rcgen::DistinguishedName::new(); + name.push(rcgen::DnType::CommonName, "jam"); + params.distinguished_name = name; + let cert = params.self_signed(&key_pair)?; + Ok(Self { + public, + cert: cert.der().clone(), + key: PrivatePkcs8KeyDer::from(key_pair.serialize_der()), + }) + } + + /// Our Ed25519 public key. + pub(super) fn public(&self) -> &[u8; 32] { + &self.public + } + + pub(super) fn cert_chain(&self) -> Vec> { + vec![self.cert.clone()] + } + + pub(super) fn private_key(&self) -> PrivateKeyDer<'static> { + PrivateKeyDer::Pkcs8(self.key.clone_key()) + } +} + +const BAD_ENCODING: rustls::Error = + rustls::Error::InvalidCertificate(rustls::CertificateError::BadEncoding); +const APP_VERIF_FAILURE: rustls::Error = + rustls::Error::InvalidCertificate(rustls::CertificateError::ApplicationVerificationFailure); +const NOT_VALID_FOR_NAME: rustls::Error = + rustls::Error::InvalidCertificate(rustls::CertificateError::NotValidForName); +const BAD_SIGNATURE: rustls::Error = + rustls::Error::InvalidCertificate(rustls::CertificateError::BadSignature); + +/// Ed25519 key of a JAMNP-S certificate, after checking its form: Ed25519 +/// SPKI, at most V3, one DNS alternative name equal to the key's text form, +/// no unknown critical extensions. +fn peer_key(cert: &CertificateDer<'_>) -> Result<[u8; 32], rustls::Error> { + let (rest, cert) = X509Certificate::from_der(cert).map_err(|_| BAD_ENCODING)?; + if !rest.is_empty() || cert.version.0 > 2 { + return Err(BAD_ENCODING); + } + let spki = &cert.subject_pki; + if spki.algorithm.algorithm != oid!(1.3.101.112) || spki.algorithm.parameters.is_some() { + return Err(APP_VERIF_FAILURE); + } + if spki.subject_public_key.unused_bits != 0 { + return Err(BAD_ENCODING); + } + let key: [u8; 32] = spki + .subject_public_key + .as_ref() + .try_into() + .map_err(|_| BAD_ENCODING)?; + let mut alternative_names = None; + for extension in cert.extensions() { + match extension.parsed_extension() { + ParsedExtension::SubjectAlternativeName(names) => { + if alternative_names.replace(names).is_some() { + return Err(BAD_ENCODING); + } + } + ParsedExtension::ParseError { .. } => return Err(BAD_ENCODING), + _ if extension.critical => { + return Err(rustls::Error::InvalidCertificate( + rustls::CertificateError::UnhandledCriticalExtension, + )); + } + _ => {} + } + } + let names = &alternative_names.ok_or(APP_VERIF_FAILURE)?.general_names; + let [GeneralName::DNSName(name)] = names.as_slice() else { + return Err(APP_VERIF_FAILURE); + }; + if *name != peer_id::ed25519_text(&key) { + return Err(APP_VERIF_FAILURE); + } + Ok(key) +} + +/// Pins one expected Ed25519 peer key for a single dial. +#[derive(Debug)] +struct PinnedPeer { + expected: [u8; 32], +} + +impl ServerCertVerifier for PinnedPeer { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + let key = peer_key(end_entity)?; + if key != self.expected { + return Err(APP_VERIF_FAILURE); + } + let ServerName::DnsName(name) = server_name else { + return Err(NOT_VALID_FOR_NAME); + }; + if name.as_ref() != peer_id::ed25519_text(&key) { + return Err(NOT_VALID_FOR_NAME); + } + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Err(rustls::Error::PeerIncompatible( + rustls::PeerIncompatible::Tls12NotOffered, + )) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + if dss.scheme != SignatureScheme::ED25519 { + return Err(rustls::Error::PeerMisbehaved( + rustls::PeerMisbehaved::SignedHandshakeWithUnadvertisedSigScheme, + )); + } + let key = peer_key(cert)?; + ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, key) + .verify(message, dss.signature()) + .map_err(|_| BAD_SIGNATURE)?; + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![SignatureScheme::ED25519] + } +} + +/// rustls client configuration for one dial: TLS 1.3, ring, our certificate, +/// the pinned peer verifier and the chain's ALPN. +pub(super) fn client_config( + identity: &Identity, + expected: [u8; 32], + alpn: Vec, +) -> Result { + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let mut config = rustls::ClientConfig::builder_with_provider(provider) + .with_protocol_versions(&[&rustls::version::TLS13]) + .expect("ring supports TLS 1.3") + .dangerous() + .with_custom_certificate_verifier(Arc::new(PinnedPeer { expected })) + .with_client_auth_cert(identity.cert_chain(), identity.private_key())?; + config.alpn_protocols = vec![alpn]; + Ok(config) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A dial names one key; any other certificate, or the right certificate + /// under another name, must fail the handshake. + #[test] + fn only_the_pinned_key_under_its_own_name_passes() { + let identity = Identity::generate().unwrap(); + let other = Identity::generate().unwrap(); + let name = |identity: &Identity| { + ServerName::try_from(peer_id::ed25519_text(identity.public())).unwrap() + }; + let verify = |expected: &Identity, server_name: &ServerName<'_>| { + PinnedPeer { + expected: *expected.public(), + } + .verify_server_cert(&identity.cert, &[], server_name, &[], UnixTime::now()) + .is_ok() + }; + + assert_eq!(peer_key(&identity.cert).unwrap(), *identity.public()); + assert_eq!( + [ + verify(&identity, &name(&identity)), + verify(&other, &name(&identity)), + verify(&identity, &name(&other)), + ], + [true, false, false], + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 58ca3e43f7..b6dbd80d6b 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -74,6 +74,8 @@ pub(crate) use vrf::ring_vrf_member; pub use signing_host::StatementRenewalTarget; #[cfg(not(target_arch = "wasm32"))] pub use signing_host::TrackedStatementRenewalTarget; +#[cfg(any(test, not(target_arch = "wasm32")))] +use tracing::Instrument; use tracing::{instrument, warn}; use truapi::api::{Chat, Pocket, Renderer}; use truapi::versioned::account::{HostAccountGetError, HostAccountSignVrfError}; @@ -84,6 +86,7 @@ use truapi::versioned::chat::{ HostChatPostMessageError, HostChatPostMessageRequest, HostChatPostMessageResponse, HostChatRegisterBotError, HostChatRegisterBotRequest, HostChatRegisterBotResponse, }; +#[cfg(any(test, not(target_arch = "wasm32")))] use truapi::versioned::jam_peer_transport::HostJamPeerTransportDialError; use truapi::versioned::pocket::{ HostPocketListSubscribeError, HostPocketListSubscribeItem, HostPocketListSubscribeRequest, @@ -310,6 +313,9 @@ pub struct ProductRuntimeHost { /// operations is the host's call, made in `begin_operation`, since the /// host is what the operations keep running. open_operations: Mutex>, + /// This connection's JAM peer connections, closed on dispose. + #[cfg(not(target_arch = "wasm32"))] + jam_peers: crate::jam_peer_transport::session::JamPeerSession, } /// A connection that goes away without ending its operations still owes the @@ -344,6 +350,8 @@ impl ProductRuntimeHost { renderer: adapters.renderer, pocket_platform: adapters.pocket_platform, open_operations: Mutex::new(HashSet::new()), + #[cfg(not(target_arch = "wasm32"))] + jam_peers: crate::jam_peer_transport::session::JamPeerSession::new(), } } @@ -473,6 +481,8 @@ impl ProductRuntimeHost { renderer, pocket_platform: None, open_operations: Mutex::new(HashSet::new()), + #[cfg(not(target_arch = "wasm32"))] + jam_peers: crate::jam_peer_transport::session::JamPeerSession::new(), }; (host, pairing_host) } @@ -767,37 +777,52 @@ impl ProductRuntimeHost { /// so a light client dialing several validators of one chain is asked once /// per genesis. Anything short of a grant, including a dismissed prompt, /// is `NotGranted`. - #[cfg_attr( - not(test), - expect( - dead_code, - reason = "the core has no native JamPeerTransport yet; its dial must call this first" - ) - )] - #[instrument(skip_all, fields(runtime.method = "jam_peer_transport.require_jam_peers"))] - pub(crate) async fn require_jam_peers( + /// + /// The check owns what it reads, so it may outlive the dial that started + /// it: an answer given after the dial stopped waiting is still persisted. + #[cfg(any(test, not(target_arch = "wasm32")))] + pub(crate) fn require_jam_peers( &self, genesis: [u8; 32], - ) -> Result<(), CallError> { + ) -> impl Future>> + Send + 'static + { + let platform = self.platform.clone(); + let product = self.product.clone(); + let permission_status = self.permission_status.clone(); + let temporary_permissions = self.temporary_permissions.clone(); let request = v01::RemotePermissionRequest { permission: v01::RemotePermission::JamPeers { genesis }, }; - match self - .permissions_service() - .check_or_prompt_remote(request) - .await - { - Ok(PermissionAuthorizationStatus::Authorized) => Ok(()), - Ok( - PermissionAuthorizationStatus::Denied - | PermissionAuthorizationStatus::NotDetermined, - ) => Err(CallError::Domain(HostJamPeerTransportDialError::V1( - v01::HostJamPeerTransportDialError::NotGranted, - ))), - Err(err) => Err(CallError::HostFailure { - reason: format!("permission storage failed: {err:?}"), - }), + async move { + let status = PermissionsService::new(platform.as_ref(), platform.as_ref(), &product) + .with_status_host(permission_status.as_deref()) + .with_temporary_permissions(temporary_permissions) + .check_or_prompt_remote(request) + .await; + match status { + Ok(PermissionAuthorizationStatus::Authorized) => Ok(()), + Ok( + PermissionAuthorizationStatus::Denied + | PermissionAuthorizationStatus::NotDetermined, + ) => Err(CallError::Domain(HostJamPeerTransportDialError::V1( + v01::HostJamPeerTransportDialError::NotGranted, + ))), + Err(err) => Err(CallError::HostFailure { + reason: format!("permission storage failed: {err:?}"), + }), + } } + .instrument(tracing::info_span!( + "require_jam_peers", + runtime.method = "jam_peer_transport.require_jam_peers" + )) + } + + /// Close this connection's JAM peer connections; every later + /// `JamPeerTransport` call is `Denied`. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn close_jam_peer_transport(&self) { + self.jam_peers.revoke(); } #[instrument(skip_all, fields(runtime.method = "permissions.identity_disclosure_authorization"))] diff --git a/rust/crates/truapi-server/src/runtime/capabilities.rs b/rust/crates/truapi-server/src/runtime/capabilities.rs index d524bde5e5..800486c623 100644 --- a/rust/crates/truapi-server/src/runtime/capabilities.rs +++ b/rust/crates/truapi-server/src/runtime/capabilities.rs @@ -2,6 +2,7 @@ mod account; mod chain; +mod jam_peer_transport; mod payment; mod platform; mod preimage; diff --git a/rust/crates/truapi-server/src/runtime/capabilities/jam_peer_transport.rs b/rust/crates/truapi-server/src/runtime/capabilities/jam_peer_transport.rs new file mode 100644 index 0000000000..356747c909 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/capabilities/jam_peer_transport.rs @@ -0,0 +1,116 @@ +//! Product-facing JAM peer transport. +//! +//! Native runtimes dial JAMNP-S QUIC through the connection's +//! [`JamPeerSession`](crate::jam_peer_transport::session::JamPeerSession), +//! gating every dial on [`ProductRuntimeHost::require_jam_peers`]. The browser +//! core keeps the trait's `NotGranted` defaults: its JavaScript session answers +//! trait 111 before frames reach the core. + +use crate::runtime::ProductRuntimeHost; + +#[cfg(target_arch = "wasm32")] +#[truapi::async_trait] +impl truapi::api::JamPeerTransport for ProductRuntimeHost {} + +#[cfg(not(target_arch = "wasm32"))] +mod native { + use tracing::instrument; + use truapi::versioned::jam_peer_transport::{ + HostJamPeerTransportCloseError, HostJamPeerTransportCloseRequest, + HostJamPeerTransportCloseResponse, HostJamPeerTransportDialError, + HostJamPeerTransportDialRequest, HostJamPeerTransportDialResponse, + HostJamPeerTransportEventsError, HostJamPeerTransportEventsRequest, + HostJamPeerTransportEventsResponse, HostJamPeerTransportOpenError, + HostJamPeerTransportOpenRequest, HostJamPeerTransportOpenResponse, + HostJamPeerTransportRecvError, HostJamPeerTransportRecvRequest, + HostJamPeerTransportRecvResponse, HostJamPeerTransportResetError, + HostJamPeerTransportResetRequest, HostJamPeerTransportResetResponse, + HostJamPeerTransportSendError, HostJamPeerTransportSendRequest, + HostJamPeerTransportSendResponse, + }; + use truapi::{CallContext, CallError}; + + use super::ProductRuntimeHost; + + #[truapi::async_trait] + impl truapi::api::JamPeerTransport for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "jam_peer_transport.dial"))] + async fn dial( + &self, + cx: &CallContext, + request: HostJamPeerTransportDialRequest, + ) -> Result> + { + let HostJamPeerTransportDialRequest::V1(inner) = &request; + let genesis = inner.genesis; + self.jam_peers + .dial( + cx, + request, + || self.require_jam_peers(genesis), + &self.services.spawner, + ) + .await + } + + #[instrument(skip_all, fields(runtime.method = "jam_peer_transport.open"))] + async fn open( + &self, + _cx: &CallContext, + request: HostJamPeerTransportOpenRequest, + ) -> Result> + { + self.jam_peers.open(request).await + } + + #[instrument(skip_all, fields(runtime.method = "jam_peer_transport.send"))] + async fn send( + &self, + _cx: &CallContext, + request: HostJamPeerTransportSendRequest, + ) -> Result> + { + self.jam_peers.send(request) + } + + #[instrument(skip_all, fields(runtime.method = "jam_peer_transport.recv"))] + async fn recv( + &self, + _cx: &CallContext, + request: HostJamPeerTransportRecvRequest, + ) -> Result> + { + self.jam_peers.recv(request) + } + + #[instrument(skip_all, fields(runtime.method = "jam_peer_transport.reset"))] + async fn reset( + &self, + _cx: &CallContext, + request: HostJamPeerTransportResetRequest, + ) -> Result> + { + self.jam_peers.reset(request) + } + + #[instrument(skip_all, fields(runtime.method = "jam_peer_transport.close"))] + async fn close( + &self, + _cx: &CallContext, + request: HostJamPeerTransportCloseRequest, + ) -> Result> + { + self.jam_peers.close(request) + } + + #[instrument(skip_all, fields(runtime.method = "jam_peer_transport.events"))] + async fn events( + &self, + _cx: &CallContext, + _request: HostJamPeerTransportEventsRequest, + ) -> Result> + { + self.jam_peers.events() + } + } +} diff --git a/rust/crates/truapi-server/src/runtime/capabilities/resources.rs b/rust/crates/truapi-server/src/runtime/capabilities/resources.rs index f84db8b279..c307289a03 100644 --- a/rust/crates/truapi-server/src/runtime/capabilities/resources.rs +++ b/rust/crates/truapi-server/src/runtime/capabilities/resources.rs @@ -17,10 +17,6 @@ use crate::runtime::{ remote_authority_call, remote_authority_context_with_default, until_cancelled, }; -// Published product runtimes have no JAM peer-transport grant. -#[truapi::async_trait] -impl truapi::api::JamPeerTransport for ProductRuntimeHost {} - #[truapi::async_trait] impl ResourceAllocation for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "resource_allocation.request"))] diff --git a/rust/crates/truapi-server/tests/live_jam_public_devnet.rs b/rust/crates/truapi-server/tests/live_jam_public_devnet.rs new file mode 100644 index 0000000000..81e052194d --- /dev/null +++ b/rust/crates/truapi-server/tests/live_jam_public_devnet.rs @@ -0,0 +1,445 @@ +//! `JamPeerTransport` through a native product runtime against the public JAM +//! devnet (`jam-public-devnet`, six validators on 51.159.188.61). +//! +//! Every call is a product frame: through the generated dispatcher into +//! `ProductRuntimeHost`, whose dial asks the platform for +//! `RemotePermission::JamPeers` and then speaks JAMNP-S QUIC to the validator +//! the frame names. + +use std::collections::{BTreeSet, HashMap}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use parity_scale_codec::{Decode, Encode}; +use truapi::versioned::jam_peer_transport as wire; +use truapi::{CallError, latest}; +use truapi_platform::ProductContext; +use truapi_platform::mock::{MockPlatform, PermissionKind}; +use truapi_server::frame::{MESSAGE_TYPE_REQUEST, Payload, ProtocolMessage, request_ids}; +use truapi_server::{FrameSink, PairingHostRuntime, ProductRuntime}; + +// Shared harness; this binary uses only part of it. +#[allow(dead_code)] +mod common; +use common::{test_runtime_config, test_spawner}; + +const GENESIS: &str = "10c123f02eb6df4c01397d797a112055be691883baa2e82f83b618ed6ce45e46"; +/// `follow.json` slot timing of the devnet. +const SLOT_EPOCH_UNIX_MS: u64 = 1_735_732_800_000; +const SLOT_DURATION_MS: u64 = 6_000; +/// Validator UDP ports on 51.159.188.61 and the Ed25519 keys their +/// certificates carry, from the devnet's `bootnodes.json`. +const BOOTNODES: [(u16, &str); 6] = [ + ( + 43000, + "5f7eac6e6a73897aca3ddcc99b763664b9c7e8474d0549d22c553bca8a3d4570", + ), + ( + 43001, + "89a6ecb6e586291ef811d8cecb338e2a61ab2f70ed62c94e2f80979d202f1250", + ), + ( + 43002, + "273bfffaf8201d658547cd718953c613088ab4d1ee2db448dc481bf165e71ce7", + ), + ( + 43003, + "976b529948b1f855ebf146a1254d126eefa4d2ceafa1f9c3328adefdca4de71e", + ), + ( + 43004, + "f6be75919e06d1b5ae290a9320c5ece980b42c9d128c2e6f522f16eb77daab96", + ), + ( + 43005, + "b22bc9fd19b4dfb2ed0bf1a550cdba3da9afc35323081ea6a456da76bf1c6bef", + ), +]; +const UP_BLOCK_ANNOUNCEMENT: u8 = 0; + +/// The tests below compare the process's UDP sockets, so they must not run +/// side by side. +static SERIAL: Mutex<()> = Mutex::new(()); + +fn bytes32(hex: &str) -> [u8; 32] { + hex::decode(hex).unwrap().try_into().unwrap() +} + +fn dial_request(port: u16, ed25519: &str) -> wire::HostJamPeerTransportDialRequest { + wire::HostJamPeerTransportDialRequest::V1(latest::HostJamPeerTransportDialRequest { + genesis: bytes32(GENESIS), + ip: std::net::Ipv4Addr::new(51, 159, 188, 61) + .to_ipv6_mapped() + .octets(), + port, + ed25519: bytes32(ed25519), + p256: None, + }) +} + +type Dialed = + Result>; + +/// Keeps each response by request id. +#[derive(Default)] +struct Responses(Mutex>>); + +impl FrameSink for Responses { + fn emit_frame(&self, frame: Vec) { + let message = ProtocolMessage::decode(&mut &frame[..]).expect("decode emitted frame"); + self.0 + .lock() + .unwrap() + .insert(message.request_id, message.payload.value); + } +} + +/// One product connection driven by frames. +struct Product { + runtime: ProductRuntime, + responses: Arc, + next_id: AtomicU64, +} + +impl Product { + fn open(platform: Arc) -> Self { + let (host_config, _) = test_runtime_config(); + let host = PairingHostRuntime::new(platform, host_config, test_spawner()); + let responses = Arc::new(Responses::default()); + let product = ProductContext::new("jam.paseo".to_string()).unwrap(); + Self { + runtime: host.product_runtime(product, responses.clone()), + responses, + next_id: AtomicU64::new(0), + } + } + + /// Send one request frame and decode its response. + fn call(&self, method: &str, request: impl Encode) -> Response { + let ids = request_ids(method).expect("registered method"); + let request_id = format!("p:{}", self.next_id.fetch_add(1, Ordering::Relaxed)); + let frame = ProtocolMessage { + request_id: request_id.clone(), + payload: Payload { + trait_id: ids.trait_id, + method_id: ids.method_id, + message_type: MESSAGE_TYPE_REQUEST, + value: request.encode(), + }, + }; + futures::executor::block_on(self.runtime.receive_frame(frame.encode())) + .expect("the dispatcher accepts the frame"); + let value = self + .responses + .0 + .lock() + .unwrap() + .remove(&request_id) + .expect("a request is answered before its dispatch returns"); + Response::decode(&mut &value[..]).expect("decode response") + } + + /// Dial every bootnode at once, as a light client does, trying a + /// refused validator up to `attempts` times: a busy validator refuses + /// some connections and a light client dials it again. + fn dial_all(&self, attempts: u32) -> Vec { + let refused = CallError::Domain(wire::HostJamPeerTransportDialError::V1( + latest::HostJamPeerTransportDialError::Refused, + )); + std::thread::scope(|scope| { + let dials: Vec<_> = BOOTNODES + .iter() + .map(|(port, ed25519)| { + let refused = &refused; + scope.spawn(move || { + let mut attempt = 1; + loop { + let dial: Dialed = + self.call("jam_peer_transport_dial", dial_request(*port, ed25519)); + if dial.as_ref().err() != Some(refused) || attempt == attempts { + return dial; + } + eprintln!("validator :{port} refused attempt {attempt}; retrying"); + attempt += 1; + std::thread::sleep(Duration::from_secs(3)); + } + }) + }) + .collect(); + dials.into_iter().map(|dial| dial.join().unwrap()).collect() + }) + } + + fn recv(&self, stream: u32) -> latest::HostJamPeerTransportRecvResponse { + let response: Result< + wire::HostJamPeerTransportRecvResponse, + CallError, + > = self.call( + "jam_peer_transport_recv", + wire::HostJamPeerTransportRecvRequest::V1(latest::HostJamPeerTransportRecvRequest { + stream, + max: latest::JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES, + }), + ); + let wire::HostJamPeerTransportRecvResponse::V1(response) = + response.expect("the stream stays readable"); + response + } +} + +/// UDP sockets this process holds, by inode. +#[cfg(target_os = "linux")] +fn udp_sockets() -> BTreeSet { + let held: BTreeSet = std::fs::read_dir("/proc/self/fd") + .unwrap() + .filter_map(|entry| std::fs::read_link(entry.ok()?.path()).ok()) + .filter_map(|target| { + let target = target.to_str()?; + target + .strip_prefix("socket:[")? + .strip_suffix(']')? + .parse() + .ok() + }) + .collect(); + ["/proc/self/net/udp", "/proc/self/net/udp6"] + .iter() + .flat_map(|table| { + std::fs::read_to_string(table) + .unwrap_or_default() + .lines() + .skip(1) + .map(str::to_owned) + .collect::>() + }) + .filter_map(|line| line.split_whitespace().nth(9)?.parse().ok()) + .filter(|inode| held.contains(inode)) + .collect() +} + +fn blake2b_256(bytes: &[u8]) -> [u8; 32] { + blake2b_simd::Params::new() + .hash_length(32) + .hash(bytes) + .as_bytes() + .try_into() + .unwrap() +} + +fn slot_now() -> u64 { + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + (now_ms - SLOT_EPOCH_UNIX_MS) / SLOT_DURATION_MS +} + +/// What one validator told us on UP 0. +#[derive(Debug, Default)] +struct Up0 { + /// Finalized slot the peer's handshake claims. + finalized_slot: Option, + /// `(slot, header hash)` of each announced block. + announced: Vec<(u32, [u8; 32])>, +} + +#[test] +#[ignore = "needs network access to the public JAM devnet"] +fn dials_every_public_devnet_validator_and_hears_block_announcements() { + let _serial = SERIAL + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let platform = Arc::new(MockPlatform::new()); + platform.grant_permission("JamPeers"); + let product = Product::open(platform.clone()); + eprintln!( + "ALPN {}", + truapi_server::jam_peer_transport::alpn(&bytes32(GENESIS)) + ); + + let started = Instant::now(); + let conns: Vec = product + .dial_all(5) + .into_iter() + .zip(BOOTNODES) + .map(|(dial, (port, _))| { + let wire::HostJamPeerTransportDialResponse::V1(dialed) = + dial.unwrap_or_else(|error| panic!("dial :{port} failed: {error:?}")); + dialed.conn + }) + .collect(); + eprintln!( + "dialed {} validators in {:?}", + conns.len(), + started.elapsed() + ); + let prompts = platform + .permission_log() + .iter() + .filter(|entry| entry.kind == PermissionKind::Remote) + .count(); + + // UP 0: Handshake = Final ++ len++[Leaf]; Final = Header Hash ++ Slot. + let mut handshake = bytes32(GENESIS).to_vec(); + handshake.extend_from_slice(&0u32.to_le_bytes()); + handshake.push(0); + let streams: Vec = conns + .iter() + .map(|&conn| { + let opened: Result< + wire::HostJamPeerTransportOpenResponse, + CallError, + > = product.call( + "jam_peer_transport_open", + wire::HostJamPeerTransportOpenRequest::V1( + latest::HostJamPeerTransportOpenRequest { + conn, + kind: UP_BLOCK_ANNOUNCEMENT, + }, + ), + ); + let wire::HostJamPeerTransportOpenResponse::V1(opened) = opened.unwrap(); + let sent: Result< + wire::HostJamPeerTransportSendResponse, + CallError, + > = product.call( + "jam_peer_transport_send", + wire::HostJamPeerTransportSendRequest::V1( + latest::HostJamPeerTransportSendRequest { + stream: opened.stream, + message: handshake.clone(), + fin: false, + }, + ), + ); + sent.unwrap(); + opened.stream + }) + .collect(); + + let mut peers: Vec = streams.iter().map(|_| Up0::default()).collect(); + let deadline = Instant::now() + Duration::from_secs(40); + while Instant::now() < deadline && peers.iter().any(|peer| peer.announced.is_empty()) { + for (peer, &stream) in peers.iter_mut().zip(&streams) { + let received = product.recv(stream); + assert!( + !received.fin && !received.reset, + "UP 0 stays open: {received:?}" + ); + let Some(message) = received.message else { + continue; + }; + if peer.finalized_slot.is_none() { + peer.finalized_slot = Some(u32::from_le_bytes(message[32..36].try_into().unwrap())); + continue; + } + // Announcement = Header ++ Final; the header's slot follows the + // parent hash, prior state root and extrinsic hash. + let header = &message[..message.len() - 36]; + let slot = u32::from_le_bytes(header[96..100].try_into().unwrap()); + peer.announced.push((slot, blake2b_256(header))); + } + std::thread::sleep(Duration::from_millis(20)); + } + let wall_slot = slot_now(); + for ((port, _), peer) in BOOTNODES.iter().zip(&peers) { + eprintln!( + "validator :{port} finalized_slot={:?} announced={:?}", + peer.finalized_slot, + peer.announced + .iter() + .map(|(slot, hash)| format!("{slot}:{}", hex::encode(&hash[..8]))) + .collect::>(), + ); + } + eprintln!("wall-clock slot {wall_slot}"); + + let events: Result< + wire::HostJamPeerTransportEventsResponse, + CallError, + > = product.call( + "jam_peer_transport_events", + wire::HostJamPeerTransportEventsRequest::V1, + ); + let wire::HostJamPeerTransportEventsResponse::V1(events) = events.unwrap(); + eprintln!("events {:?}", events.events); + assert!( + !events + .events + .iter() + .any(|event| matches!(event, latest::JamPeerTransportEvent::ConnClosed { .. })), + "no validator dropped us: {:?}", + events.events, + ); + + let live = |peer: &Up0| { + peer.finalized_slot.is_some() + && peer.announced.iter().any(|&(slot, _)| { + (wall_slot.saturating_sub(5)..=wall_slot + 1).contains(&u64::from(slot)) + }) + }; + let blocks: BTreeSet<_> = peers + .iter() + .flat_map(|peer| peer.announced.iter()) + .collect(); + let shared_blocks = blocks + .iter() + .filter(|&&block| { + peers + .iter() + .filter(|peer| peer.announced.contains(block)) + .count() + > 1 + }) + .count(); + assert_eq!( + ( + prompts, + peers.iter().filter(|peer| live(peer)).count(), + shared_blocks > 0 + ), + (1, BOOTNODES.len(), true), + "one prompt covers every dial; each validator completes the UP 0 handshake and announces \ + a block at the wall-clock slot; validators agree on at least one block", + ); + + for conn in conns { + let closed: Result< + wire::HostJamPeerTransportCloseResponse, + CallError, + > = product.call( + "jam_peer_transport_close", + wire::HostJamPeerTransportCloseRequest::V1(latest::HostJamPeerTransportCloseRequest { + conn, + }), + ); + closed.unwrap(); + } + product.runtime.dispose(); +} + +/// A refused product must not reach the network at all: no dial binds a +/// socket, so no packet can leave for the validators it named. +#[cfg(target_os = "linux")] +#[test] +fn a_refused_product_opens_no_socket_for_any_dial() { + let _serial = SERIAL + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let platform = Arc::new(MockPlatform::new()); + platform.revoke_permission("JamPeers"); + let product = Product::open(platform.clone()); + let before = udp_sockets(); + + let dials = product.dial_all(1); + + let not_granted = CallError::Domain(wire::HostJamPeerTransportDialError::V1( + latest::HostJamPeerTransportDialError::NotGranted, + )); + assert_eq!( + (dials, udp_sockets().difference(&before).count()), + (vec![Err(not_granted); BOOTNODES.len()], 0), + ); + product.runtime.dispose(); +} From 372d6962784c5f3eed5a66d52d0a8371ac9c17d5 Mon Sep 17 00:00:00 2001 From: w Date: Mon, 28 Sep 2026 22:57:12 -0400 Subject: [PATCH 09/36] fix(truapi): pin distinct-serial PolkaJAM WebTransport certificates Stock PolkaJAM serves every WebTransport certificate with issuer CN=jam and serial 0. NSS rejects a second, different certificate with the same issuer and serial (SEC_ERROR_REUSED_ISSUER_AND_SERIAL), so Firefox reaches only one validator. jam-explore's polkajam-webtransport-serial.patch derives the serial from the P-256 key and validity period: the first 8 bytes of SHA-256(compressed key || period as big-endian u64), top bit cleared, 1 if zero. The browser adapter now pins both variants per period (distinct, then legacy serial 0), so hosts reach stock nodes as before and every patched node in Firefox. Vectors are the certificates real patched and stock nodes served, plus hashes from the patched node's own cert code. --- .changeset/pvm-jam-peer-transport.md | 5 + .../src/jam-peer-transport-cert.test.ts | 119 ++++++++++++------ .../truapi/src/jam-peer-transport-cert.ts | 77 +++++++++--- .../truapi/src/jam-peer-transport.test.ts | 5 +- 4 files changed, 155 insertions(+), 51 deletions(-) diff --git a/.changeset/pvm-jam-peer-transport.md b/.changeset/pvm-jam-peer-transport.md index 99311906db..2f0935d01c 100644 --- a/.changeset/pvm-jam-peer-transport.md +++ b/.changeset/pvm-jam-peer-transport.md @@ -12,3 +12,8 @@ Ships the browser WebTransport adapter, whose `createJamPeerTransportSession({ a session, and the deterministic PolkaJAM certificate-hash derivation under `@parity/truapi/jam-peer-transport`. Native Rust product runtimes (iOS, Android, CLI) serve the service over JAMNP-S QUIC with the same session rules, and the iOS and Android hosts show their remote-permission prompt for `JamPeers`. + +The browser adapter pins two certificates per validity period: PolkaJAM's stock serial-0 certificate and one whose +serial is derived from the peer's P-256 key and period (first 8 bytes of SHA-256(compressed key ‖ period as big-endian +u64), top bit cleared, 1 if zero). Firefox's NSS rejects a second certificate with the same issuer and serial, so with +stock nodes it reaches one validator; nodes that use the derived serial are all reachable, and stock nodes keep working. diff --git a/js/packages/truapi/src/jam-peer-transport-cert.test.ts b/js/packages/truapi/src/jam-peer-transport-cert.test.ts index 011f5b34a3..85d70f8fe2 100644 --- a/js/packages/truapi/src/jam-peer-transport-cert.test.ts +++ b/js/packages/truapi/src/jam-peer-transport-cert.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { sha256 } from "@noble/hashes/sha2.js"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; import { decompressP256, @@ -10,37 +11,63 @@ import { webTransportCertificateDer, webTransportCertificateHash, webTransportCertificateHashes, + webTransportSerial, } from "./jam-peer-transport-cert.js"; -// Vectors produced by rcgen 0.14.8 / p256 0.13.2 (the PolkaJAM dd9af78 -// lockfile versions) following PolkaJAM's `net/cert.rs`, at unix time -// 1790380800. -const VECTORS = [ +// Two validators of a local network running PolkaJAM dd9af78 with +// jam-explore's `polkajam-webtransport-serial.patch`. `der2072` is the +// certificate each node served during period 2072, dumped with +// `openssl s_client -quic -alpn h3 -showcerts`. The other `distinct` hashes +// come from the patched node's own `net/cert.rs` for those periods; the +// `legacy` (serial 0) hashes from jam-explore `crates/jam-webtransport-cert`, +// whose legacy output equals the stock certificate in `STOCK`. +const PATCHED = [ { - id: "vie5obg5rgcfrtqgw2vm37t7l4mssjdncce33wdf5tfndfjqsg6ba", - compressed: "028874174c8f469438a1b1bab2fde75f9c4999461382ec6d47e9b3b4511294c607", - hashes: { - 2071: "ccf30196b29007b42fca6f406363ce17781bab0f011bac47dcbe307e0e6a316d", - 2072: "eb09b6b027f5953cb8ca2e8f296e21052c3423370876e67f1634180ddf99f1ec", - 2073: "8bdfa3a2b7822822f5da33fadfa118d39d05b0a6b1086fc82a62a2209f6fae27", + id: "v3bl2cgtywlclprhhuc5tumdn4ulhwir2mahkcqm6tkdbyo6v2hba", + compressed: "023b2c2dccc47689f5e23954f449d9689cae6351d40c1c2520f3538d809daffa04", + serial2072: 0x20dbcba0be3fb21cn, + distinct: { + 2071: "45b4746857e99aef73adc8d14599ad772ea2e209fb2b78b24956e3c7d43090f8", + 2072: "31f457efb35cc02a9db8c4b725a20626828dce21648d7b498663cff82131ea49", + 2073: "9e8398400a3c76597747a04b86315f8c5272742065c70d490412e80bd7538417", + }, + legacy: { + 2071: "8dec8b1989d2d284b2a6ab179ee6c78805907d6b1b97f338fa7a6cf3377b9549", + 2072: "6e7e01e8e40edfeba2a56b4555b5e6dba01cdd4e8a2650bd2890d5ab559a2f97", + 2073: "396d273a20dcf3cf067fbd2ce00482bb86a7c0a33ef7bc278c1fbe930be53401", }, der2072: - "3082013d3081f0a003020102020100300506032b6570300e310c300a06035504030c036a616d301e170d3236303932333030303030305a170d3236313030353030303030305a300e310c300a06035504030c036a616d3059301306072a8648ce3d020106082a8648ce3d030107034200048874174c8f469438a1b1bab2fde75f9c4999461382ec6d47e9b3b4511294c6073947ad8ea69d98ff2844bb02f2231ceb65fadc22aa4f529b602182f6ab5ec924a344304230400603551d11043930378235766965356f62673572676366727471677732766d333774376c346d73736a646e63636533337764663574666e64666a717367366261300506032b657003410000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "308201443081f7a003020102020820dbcba0be3fb21c300506032b6570300e310c300a06035504030c036a616d301e170d3236303932333030303030305a170d3236313030353030303030305a300e310c300a06035504030c036a616d3059301306072a8648ce3d020106082a8648ce3d030107034200043b2c2dccc47689f5e23954f449d9689cae6351d40c1c2520f3538d809daffa0498fb2c7ecfde6e286fd1c9203c1c8e9d50f37bce6b571b60d3978617424cfd48a344304230400603551d110439303782357633626c3263677479776c636c7072686875633574756d646e34756c68776972326d61686b63716d36746b6462796f367632686261300506032b657003410000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", }, { - id: "o5edmn6gwjzmsyahsffsiu4kp3ao6ufexbp2tpzj2jhuplxbb3dxb", - compressed: "039d0cd6bcb1293389c191a54844b97a1b384f0bb9e1e9f972d2e9d0b76e087bdc", - hashes: { - 2071: "13589df87a7a37dd3c800d2a724568d0e5d049e27655cb1d8b6a492268be7bb4", - 2072: "2d25e5bf1695ee00c9a197cef8dfa3daa5ee9d6ddf905eebe3bdf58f464e19cd", - 2073: "1798ee46ac715588b3df8561f85f9717497755eac87077439bc0c84a37443e26", + id: "oyhjn3rnl7vx235ayvn2f4uhsyorfflk5tnrn5s3d6erxacv3vjla", + compressed: "03f8a4b6635bbf5ebd3bc0b5e9c2e991d8c55296eab3c5d6e51e9ec40b44dd352d", + serial2072: 0x4d2e1400cc6928d7n, + distinct: { + 2071: "6322b9d00cf2522232687f7bd0bf78a5127e96dd74d1b162870cb7cf5529b7b6", + 2072: "7fa6a214c42db09966ee8b673ba989a0b8235963972bbf635cef03be2f60c1d1", + 2073: "1f21b2db288a5e7e5522f947b2224fa87cdcb28acf54fa64f8699ce9145514ce", + }, + legacy: { + 2071: "f82ad0ada188038205f02ec01311ac0570ec5a7e42a3eff9eebfd047d75557f4", + 2072: "5cc86b9b585a3d91f049084caadc9ab66e2d3d75bd4551e4a2dc9d418675b423", + 2073: "08cab2920d2fc1914fcdd0e0ba3399b42a7a4199a2c3ec19048bfd4f735f6cfe", }, + der2072: + "308201443081f7a00302010202084d2e1400cc6928d7300506032b6570300e310c300a06035504030c036a616d301e170d3236303932333030303030305a170d3236313030353030303030305a300e310c300a06035504030c036a616d3059301306072a8648ce3d020106082a8648ce3d03010703420004f8a4b6635bbf5ebd3bc0b5e9c2e991d8c55296eab3c5d6e51e9ec40b44dd352df4537bba2c6db972303b6c2ba95d5c9ae402f556032e2f058e1a569ffabd457fa344304230400603551d110439303782356f79686a6e33726e6c3776783233356179766e326634756873796f7266666c6b35746e726e357333643665727861637633766a6c61300506032b657003410000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", }, ] as const; +// Certificate a stock `jam-public-devnet` validator served during period 2072. +const STOCK = { + compressed: "03aac17e3833a6679e7064934a6f2a2bc9450d3b2b779a9930102ae16a4f16062e", + der2072: + "3082013d3081f0a003020102020100300506032b6570300e310c300a06035504030c036a616d301e170d3236303932333030303030305a170d3236313030353030303030305a300e310c300a06035504030c036a616d3059301306072a8648ce3d020106082a8648ce3d03010703420004aac17e3833a6679e7064934a6f2a2bc9450d3b2b779a9930102ae16a4f16062ef93e4094a88912344d64606e51e6ec210633140ad0d6d3c3d292690171463813a344304230400603551d110439303782356f6b6e713568346d6767357a346a79726d7475733667766d666a6f723271356d66787467746a7961636b6a797677687a6367716c61300506032b657003410000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", +} as const; + describe("PolkaJAM peer id text", () => { test("decodes P-256 ids to the compressed point and back", () => { - for (const vector of VECTORS) { + for (const vector of PATCHED) { const compressed = p256IdToCompressed(vector.id); expect(bytesToHex(compressed)).toBe(vector.compressed); expect(peerIdText(vector.id[0]!, compressed.subarray(1))).toBe(vector.id); @@ -50,7 +77,7 @@ describe("PolkaJAM peer id text", () => { test("decodes Ed25519 ids and rejects the wrong prefix", () => { const key = ed25519IdToKey("e5ayk2kkzlxdvih2pud5ndhb4qtj2ub4hnpkwmonlma4i55xm6wra"); expect(peerIdText("e", key)).toBe("e5ayk2kkzlxdvih2pud5ndhb4qtj2ub4hnpkwmonlma4i55xm6wra"); - expect(() => ed25519IdToKey(VECTORS[0].id)).toThrow("begin with 'e'"); + expect(() => ed25519IdToKey(PATCHED[0].id)).toThrow("begin with 'e'"); expect(() => p256IdToCompressed("e5ayk2kkzlxdvih2pud5ndhb4qtj2ub4hnpkwmonlma4i55xm6wra")).toThrow( "'o' or 'v'", ); @@ -58,15 +85,17 @@ describe("PolkaJAM peer id text", () => { }); describe("P-256 decompression", () => { - test("matches the uncompressed point rcgen embedded", () => { - const point = decompressP256(hexToBytes(VECTORS[0].compressed)); - // The SPKI BIT STRING in der2072 carries 0x04 ‖ x ‖ y. - const index = VECTORS[0].der2072.indexOf("03420004") + 6; - expect(bytesToHex(point)).toBe(VECTORS[0].der2072.slice(index, index + 130)); + test("matches the uncompressed point the node embedded", () => { + for (const vector of PATCHED) { + const point = decompressP256(hexToBytes(vector.compressed)); + // The SPKI BIT STRING carries 0x04 ‖ x ‖ y. + const index = vector.der2072.indexOf("03420004") + 6; + expect(bytesToHex(point)).toBe(vector.der2072.slice(index, index + 130)); + } }); test("rejects off-curve x", () => { - const bad = hexToBytes(VECTORS[0].compressed); + const bad = hexToBytes(PATCHED[0].compressed); bad[32] ^= 1; expect(() => decompressP256(bad)).toThrow("not on the curve"); }); @@ -80,21 +109,41 @@ describe("validity periods", () => { }); describe("certificate derivation", () => { - test("reproduces the rcgen DER byte for byte", () => { - const der = webTransportCertificateDer(hexToBytes(VECTORS[0].compressed), 2072); - expect(bytesToHex(der)).toBe(VECTORS[0].der2072); + test("reproduces the certificates real nodes served byte for byte", () => { + for (const vector of PATCHED) { + const compressed = hexToBytes(vector.compressed); + expect(webTransportSerial(compressed, 2072)).toBe(vector.serial2072); + expect(bytesToHex(webTransportCertificateDer(compressed, 2072, "distinct"))).toBe(vector.der2072); + } + expect(bytesToHex(webTransportCertificateDer(hexToBytes(STOCK.compressed), 2072, "legacy"))).toBe( + STOCK.der2072, + ); + }); + + test("encodes a serial with a zero top byte as a minimal positive INTEGER", () => { + // Serial 0x00ada8453f66c43e: drop the zero byte, then pad because 0xad has the sign bit set. + const compressed = hexToBytes(PATCHED[0].compressed); + expect(webTransportSerial(compressed, 2099)).toBe(0x00ada8453f66c43en); + const der = webTransportCertificateDer(compressed, 2099, "distinct"); + expect(bytesToHex(der.subarray(12, 22))).toBe("020800ada8453f66c43e"); + // Hash of the patched node's own certificate for this period. + expect(bytesToHex(sha256(der))).toBe("2d1583ea892ae8c792fc499d2091f91d9ef9c4973ff3002eea577e639bee8800"); }); - test("hashes match the Rust cross-check for both y parities", () => { - for (const vector of VECTORS) { + test("pins both serial variants for the current period and both neighbours", () => { + for (const vector of PATCHED) { const compressed = hexToBytes(vector.compressed); - for (const [period, hash] of Object.entries(vector.hashes)) { - expect(bytesToHex(webTransportCertificateHash(compressed, Number(period)))).toBe(hash); + for (const period of [2071, 2072, 2073] as const) { + expect(bytesToHex(webTransportCertificateHash(compressed, period, "distinct"))).toBe(vector.distinct[period]); + expect(bytesToHex(webTransportCertificateHash(compressed, period, "legacy"))).toBe(vector.legacy[period]); } expect(webTransportCertificateHashes(compressed, 1_790_380_800).map(bytesToHex)).toEqual([ - vector.hashes[2071], - vector.hashes[2072], - vector.hashes[2073], + vector.distinct[2071], + vector.legacy[2071], + vector.distinct[2072], + vector.legacy[2072], + vector.distinct[2073], + vector.legacy[2073], ]); } }); diff --git a/js/packages/truapi/src/jam-peer-transport-cert.ts b/js/packages/truapi/src/jam-peer-transport-cert.ts index ed052f9e11..b0c923b3ed 100644 --- a/js/packages/truapi/src/jam-peer-transport-cert.ts +++ b/js/packages/truapi/src/jam-peer-transport-cert.ts @@ -4,13 +4,20 @@ import { sha256 } from "@noble/hashes/sha2.js"; * Deterministic WebTransport certificate hashes for a PolkaJAM peer. * * PolkaJAM (`crates/node/src/net/cert.rs`, `dd9af78`) serves an unsigned X.509 - * certificate for its P-256 peer key: serial 0, issuer and subject `CN=jam`, - * one dNSName SAN equal to the peer-id text, Ed25519 signature algorithm with - * an all-zero 64-byte signature, and a validity window derived from a fixed - * 10-day period padded by one day on both sides. A client that knows the - * peer's compressed P-256 key can therefore compute the certificate hashes - * offline and pass them as `serverCertificateHashes`. These bytes mirror - * PolkaJAM's `crates/node/src/net/cert.rs` generated with rcgen 0.14.8. + * certificate for its P-256 peer key: issuer and subject `CN=jam`, one dNSName + * SAN equal to the peer-id text, Ed25519 signature algorithm with an all-zero + * 64-byte signature, and a validity window derived from a fixed 10-day period + * padded by one day on both sides. A client that knows the peer's compressed + * P-256 key can therefore compute the certificate hashes offline and pass them + * as `serverCertificateHashes`. These bytes mirror PolkaJAM's + * `crates/node/src/net/cert.rs` generated with rcgen 0.14.8. + * + * Stock PolkaJAM gives every certificate serial 0. NSS (Firefox) rejects a + * second certificate with an issuer and serial it has already seen + * (`SEC_ERROR_REUSED_ISSUER_AND_SERIAL`), so such a browser reaches only one + * validator. Nodes built with jam-explore's + * `polkajam-webtransport-serial.patch` use {@link webTransportSerial} instead; + * clients pin both variants so they reach stock and patched nodes alike. */ export const UNPADDED_VALIDITY_PERIOD_SECS = 10 * 24 * 3600; @@ -133,6 +140,14 @@ function der(tag: number, ...parts: ArrayLike[]): number[] { return header.concat(body); } +/** Minimal DER encoding of a non-negative INTEGER. */ +function derUnsigned(v: bigint): number[] { + const bytes: number[] = []; + for (; v > 0n; v >>= 8n) bytes.unshift(Number(v & 0xffn)); + if (bytes.length === 0 || bytes[0]! >= 0x80) bytes.unshift(0); + return der(0x02, bytes); +} + function utcTime(unixSecs: number): number[] { const date = new Date(unixSecs * 1000); const year = date.getUTCFullYear(); @@ -162,8 +177,32 @@ export function validityBounds(period: number): [number, number] { ]; } +/** + * Which serial a certificate carries: `distinct` ({@link webTransportSerial}, + * patched nodes) or `legacy` (0, stock PolkaJAM). + */ +export type CertificateSerial = "distinct" | "legacy"; + +/** + * Serial of the patched PolkaJAM certificate for `compressed` during + * `period`: the first 8 bytes of SHA-256(`compressed` ‖ period as a + * big-endian u64), read big-endian with the top bit cleared, or 1 if that + * is 0. + */ +export function webTransportSerial(compressed: Uint8Array, period: number): bigint { + const input = new Uint8Array(compressed.length + 8); + input.set(compressed); + input.set(bytesFromBigint(BigInt(period), 8), compressed.length); + const serial = bigintFromBytes(sha256(input).subarray(0, 8)) & ((1n << 63n) - 1n); + return serial === 0n ? 1n : serial; +} + /** DER certificate PolkaJAM presents for `compressed` during `period`. */ -export function webTransportCertificateDer(compressed: Uint8Array, period: number): Uint8Array { +export function webTransportCertificateDer( + compressed: Uint8Array, + period: number, + serial: CertificateSerial, +): Uint8Array { const point = decompressP256(compressed); const altName = peerIdText(compressed[0] === 3 ? "o" : "v", compressed.subarray(1)); const [notBefore, notAfter] = validityBounds(period); @@ -172,7 +211,7 @@ export function webTransportCertificateDer(compressed: Uint8Array, period: numbe const tbs = der( 0x30, der(0xa0, der(0x02, [0x02])), - der(0x02, [0x00]), + derUnsigned(serial === "distinct" ? webTransportSerial(compressed, period) : 0n), ED25519_ALG, JAM_DN, der(0x30, utcTime(notBefore), utcTime(notAfter)), @@ -184,18 +223,26 @@ export function webTransportCertificateDer(compressed: Uint8Array, period: numbe } /** SHA-256 of {@link webTransportCertificateDer}. */ -export function webTransportCertificateHash(compressed: Uint8Array, period: number): Uint8Array { - return sha256(webTransportCertificateDer(compressed, period)); +export function webTransportCertificateHash( + compressed: Uint8Array, + period: number, + serial: CertificateSerial, +): Uint8Array { + return sha256(webTransportCertificateDer(compressed, period, serial)); } /** - * Hashes to pass as `serverCertificateHashes` at `unixSecs`: the current - * period plus both neighbours, so a clock skew or a boundary crossing during - * the handshake still matches whichever certificate the server picked. + * Hashes to pass as `serverCertificateHashes` at `unixSecs`: both serial + * variants for the current period plus both neighbours, so a clock skew or a + * boundary crossing during the handshake still matches whichever certificate + * a stock or patched server picked. */ export function webTransportCertificateHashes(compressed: Uint8Array, unixSecs: number): Uint8Array[] { const period = validityPeriodAt(unixSecs); return [period - 1, period, period + 1] .filter((p) => p >= 0) - .map((p) => webTransportCertificateHash(compressed, p)); + .flatMap((p) => [ + webTransportCertificateHash(compressed, p, "distinct"), + webTransportCertificateHash(compressed, p, "legacy"), + ]); } diff --git a/js/packages/truapi/src/jam-peer-transport.test.ts b/js/packages/truapi/src/jam-peer-transport.test.ts index 8e756d0520..88d4c0aaa0 100644 --- a/js/packages/truapi/src/jam-peer-transport.test.ts +++ b/js/packages/truapi/src/jam-peer-transport.test.ts @@ -278,13 +278,16 @@ describe("authorization", () => { }); describe("dial", () => { - test("connects to the peer URL with the three period certificate hashes", async () => { + test("connects to the peer URL with both serial variants of the three period certificate hashes", async () => { const { transport, conn } = await dialed(); expect(conn).toBe(1); expect(transport.url).toBe("https://127.0.0.1:43000"); expect(transport.hashes.map((h) => S.bytesToHex(h))).toEqual([ + "0x51fc12ea78bc97eb7d969bd4ff221f2063f205111893cbff22cd9a1b5f8c8ad6", "0xccf30196b29007b42fca6f406363ce17781bab0f011bac47dcbe307e0e6a316d", + "0x7d89ee4abc9820e55e5f8c3b9cdfb10967391be26707f5d3397bf2bd46cdea08", "0xeb09b6b027f5953cb8ca2e8f296e21052c3423370876e67f1634180ddf99f1ec", + "0x505c184ed39a7dfa39876a5a1734d118f8fb9ad90932b78d7c90d3a062646224", "0x8bdfa3a2b7822822f5da33fadfa118d39d05b0a6b1086fc82a62a2209f6fae27", ]); }); From 6e6c9ddfc373572ba501b9064245f5718adee52b Mon Sep 17 00:00:00 2001 From: w Date: Tue, 29 Sep 2026 11:39:02 -0400 Subject: [PATCH 10/36] Regenerate peer contract against unified host and current primitives --- rust/crates/truapi-client/src/generated.rs | 2 +- rust/crates/truapi/src/lib.rs | 56 +++++++++---------- .../tests/jam_peer_transport_contract.rs | 4 +- .../truapi/tests/live_jam_public_devnet.rs | 6 +- 4 files changed, 33 insertions(+), 35 deletions(-) diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index 06e2ba6b21..3d6651886e 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "03ed2f7272b4248c"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "63a4d02d1ddf930b"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 3d3766d875..98f818df10 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -87,38 +87,36 @@ pub mod latest { use crate::versioned::{self, Versioned}; pub use crate::v01::{ - AccountId, AllocatableResource, AllocationOutcome, Arrangement, Background, BlendingMode, - BorderStyle, BoxProps, ButtonProps, ButtonVariant, ChainIdentifier, ChatAction, - ChatActionLayout, ChatActions, ChatBotRegistrationStatus, ChatCustomMessage, ChatFile, - ChatMedia, ChatMessageContent, ChatReaction, ChatRichText, ChatRoom, ChatRoomParticipation, + AllocatableResource, AllocationOutcome, Arrangement, Background, BlendingMode, BorderStyle, + BoxProps, ButtonProps, ButtonVariant, ChainIdentifier, ChatAction, ChatActionLayout, + ChatActions, ChatBotRegistrationStatus, ChatCustomMessage, ChatFile, ChatMedia, + ChatMessageContent, ChatReaction, ChatRichText, ChatRoom, ChatRoomParticipation, ChatRoomRegistrationStatus, ColorToken, ColumnProps, ContactHandle, ContactPickOutcome, - ContentAlignment, ContextualAlias, - DerivationIndex, Dimensions, Effect, EffectProps, GenericError, HorizontalAlignment, - HostAccountCreateProofRequest, HostAccountGetAliasRequest, - HostAccountListRingVrfKeysRequest, HostAccountRegisterRingVrfKeyRequest, - HostAccountRingVrfSignRequest, HostAccountSignVrfError, HostAccountSignVrfRequest, - HostJamPeerTransportCloseError, HostJamPeerTransportCloseRequest, - HostJamPeerTransportDialError, HostJamPeerTransportDialRequest, - HostJamPeerTransportDialResponse, HostJamPeerTransportEventsError, - HostJamPeerTransportEventsResponse, HostJamPeerTransportOpenError, - HostJamPeerTransportOpenRequest, HostJamPeerTransportOpenResponse, - HostJamPeerTransportRecvError, HostJamPeerTransportRecvRequest, - HostJamPeerTransportRecvResponse, HostJamPeerTransportResetError, - HostJamPeerTransportResetRequest, HostJamPeerTransportSendError, - HostJamPeerTransportSendRequest, HostPlatform, HostSignPayloadData, - HostWorkerOperationError, ImageFit, ImageProps, ImageSource, + ContentAlignment, ContextualAlias, DerivationIndex, Dimensions, Effect, EffectProps, + GenericError, HorizontalAlignment, HostAccountCreateProofRequest, + HostAccountGetAliasRequest, HostAccountListRingVrfKeysRequest, + HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest, + HostAccountSignVrfError, HostAccountSignVrfRequest, HostJamPeerTransportCloseError, + HostJamPeerTransportCloseRequest, HostJamPeerTransportDialError, + HostJamPeerTransportDialRequest, HostJamPeerTransportDialResponse, + HostJamPeerTransportEventsError, HostJamPeerTransportEventsResponse, + HostJamPeerTransportOpenError, HostJamPeerTransportOpenRequest, + HostJamPeerTransportOpenResponse, HostJamPeerTransportRecvError, + HostJamPeerTransportRecvRequest, HostJamPeerTransportRecvResponse, + HostJamPeerTransportResetError, HostJamPeerTransportResetRequest, + HostJamPeerTransportSendError, HostJamPeerTransportSendRequest, HostPlatform, + HostSignPayloadData, HostWorkerOperationError, ImageFit, ImageProps, ImageSource, JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION, JAM_PEER_TRANSPORT_MAX_CONNECTIONS, JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES, JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION, - JamPeerTransportEvent, Modifier, NotificationId, OperationId, OperationStartedResult, - PocketCard, ProductAccountId, ProductProofContext, RawPayload, RegisteredRingVrfKey, - RemotePermission, RemoteStatementStoreCreateProofError, - RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, - RemoteStatementStoreSubscribeItem, RemoteStatementStoreSubscribeRequest, RenderContext, - RendererNode, RingLocation, RingLocationJunction, RingVrfKeyDisclosure, RingVrfPublicKey, - RowProps, RuntimeApi, RuntimeSpec, RuntimeType, Shape, SignedStatement, Size, Statement, - StatementProof, StorageQueryItem, StorageQueryType, StorageResultItem, TextFieldProps, - TextProps, ThemeName, ThemeVariant, TxPayloadExtension, TypographyStyle, VerticalAlignment, - VrfSignature, + JamPeerTransportEvent, Modifier, OperationStartedResult, PocketCard, ProductAccountId, + ProductProofContext, RawPayload, RegisteredRingVrfKey, RemotePermission, + RemoteStatementStoreCreateProofError, RemoteStatementStoreCreateProofRequest, + RemoteStatementStoreCreateProofResponse, RemoteStatementStoreSubscribeItem, + RemoteStatementStoreSubscribeRequest, RenderContext, RendererNode, RingLocation, + RingLocationJunction, RingVrfKeyDisclosure, RowProps, RuntimeApi, RuntimeSpec, RuntimeType, + Shape, SignedStatement, Size, Statement, StatementProof, StorageQueryItem, + StorageQueryType, StorageResultItem, TextFieldProps, TextProps, ThemeName, ThemeVariant, + TxPayloadExtension, TypographyStyle, VerticalAlignment, VrfSignature, }; /// Latest payload type of a versioned envelope. diff --git a/rust/crates/truapi/tests/jam_peer_transport_contract.rs b/rust/crates/truapi/tests/jam_peer_transport_contract.rs index 9c1ca9989c..38b541de2a 100644 --- a/rust/crates/truapi/tests/jam_peer_transport_contract.rs +++ b/rust/crates/truapi/tests/jam_peer_transport_contract.rs @@ -5,14 +5,14 @@ //! host uses on dial. use parity_scale_codec::{Decode, Encode}; -use truapi::latest; -use truapi::versioned::jam_peer_transport; use truapi::generated::wire_table::{ JAM_PEER_TRANSPORT_CLOSE, JAM_PEER_TRANSPORT_DIAL, JAM_PEER_TRANSPORT_EVENTS, JAM_PEER_TRANSPORT_OPEN, JAM_PEER_TRANSPORT_RECV, JAM_PEER_TRANSPORT_RESET, JAM_PEER_TRANSPORT_SEND, MethodIds, }; use truapi::jam_peer_transport::{InvalidGenesis, alpn, parse_genesis}; +use truapi::latest; +use truapi::versioned::jam_peer_transport; const GENESIS_HEX: &str = "353963b9cedfe4ea22038081052a5c151b06b55a4a026a97522cd0320cabf49f"; diff --git a/rust/crates/truapi/tests/live_jam_public_devnet.rs b/rust/crates/truapi/tests/live_jam_public_devnet.rs index 4b6585d7b1..007be5ad03 100644 --- a/rust/crates/truapi/tests/live_jam_public_devnet.rs +++ b/rust/crates/truapi/tests/live_jam_public_devnet.rs @@ -12,11 +12,11 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use parity_scale_codec::{Decode, Encode}; -use truapi::versioned::jam_peer_transport as wire; -use truapi::{CallError, latest}; +use truapi::frame::{MESSAGE_TYPE_REQUEST, Payload, ProtocolMessage, request_ids}; use truapi::platform::ProductContext; use truapi::platform::mock::{MockPlatform, PermissionKind}; -use truapi::frame::{MESSAGE_TYPE_REQUEST, Payload, ProtocolMessage, request_ids}; +use truapi::versioned::jam_peer_transport as wire; +use truapi::{CallError, latest}; use truapi::{FrameSink, PairingHostRuntime, ProductRuntime}; // Shared harness; this binary uses only part of it. From 7f952cfd523f0a9eeba056b02aa2a2749ac91603 Mon Sep 17 00:00:00 2001 From: w Date: Tue, 29 Sep 2026 14:11:53 -0400 Subject: [PATCH 11/36] fix(ci): retain iOS test failure diagnostics --- .github/workflows/ios-pr.yml | 24 +++++++++++++++++++----- hosts/ios/README.md | 9 +++++++++ hosts/ios/fastlane/lanes/lane_tests.rb | 15 ++++++--------- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ios-pr.yml b/.github/workflows/ios-pr.yml index df440f4c9c..b06f0fdea3 100644 --- a/.github/workflows/ios-pr.yml +++ b/.github/workflows/ios-pr.yml @@ -91,8 +91,8 @@ env: # DEBUG MODE: Set to 'true' to enable enhanced debugging for build failures # What it does: # - Enables verbose xcodebuild logging - # - Collects and uploads build artifacts (logs, test results) - # - Continues build even on failures to gather maximum information + # - Collects and uploads build artifacts + # Test results and diagnostics are uploaded regardless of this setting. DEBUG_CI: false jobs: @@ -469,13 +469,27 @@ jobs: bundle exec fastlane run_unit_tests fi - # Upload test artifacts for debugging - only when DEBUG_CI is enabled + - name: Export test diagnostics + if: always() + working-directory: hosts/ios + run: | + set -euo pipefail + result="fastlane/test_output/polkadot-app.xcresult" + if [[ -d "$result" ]]; then + xcrun xcresulttool get test-results summary --path "$result" + xcrun xcresulttool get test-results tests --path "$result" + xcrun xcresulttool export diagnostics --path "$result" \ + --output-path fastlane/test_output/diagnostics + fi + - name: Upload test artifacts - if: env.DEBUG_CI == 'true' && (failure() || success()) + if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: test-artifacts-${{ github.run_number }} + name: test-artifacts-${{ github.run_id }}-${{ github.run_attempt }} path: | hosts/ios/fastlane/test_output/ hosts/ios/fastlane/build_logs/ + ~/Library/Logs/DiagnosticReports/*.ips + ~/Library/Logs/DiagnosticReports/*.crash retention-days: 3 diff --git a/hosts/ios/README.md b/hosts/ios/README.md index 5e55dd258b..945ae031e9 100644 --- a/hosts/ios/README.md +++ b/hosts/ios/README.md @@ -90,6 +90,15 @@ xcodebuild test -project polkadot-app.xcodeproj -scheme polkadot-appTests \ -destination 'platform=iOS Simulator,name=iPhone 16' ``` +The iOS CI test job runs `RUN_IN_CI=true bundle exec fastlane run_unit_tests` +from `hosts/ios`. It retains the `.xcresult` bundle, raw xcodebuild log, +simulator logs, and available crash reports in +`test-artifacts--` for three days, even when tests fail. +The job log also contains the `xcresulttool` test summary and test list. +Download the artifact from the workflow run and open its `.xcresult` in Xcode +to inspect individual failures and diagnostics. `debug:true` adds raw console +output without changing whether test failures fail the job. + ## How it works Polkadot iOS is a self-custodial superapp: your keys are created on your phone, stay on your phone, and everything else — identity, chat, payments, apps — is built on top of them using Polkadot's public chains instead of company servers. diff --git a/hosts/ios/fastlane/lanes/lane_tests.rb b/hosts/ios/fastlane/lanes/lane_tests.rb index 559b6648bd..e88b0bb4ea 100644 --- a/hosts/ios/fastlane/lanes/lane_tests.rb +++ b/hosts/ios/fastlane/lanes/lane_tests.rb @@ -19,19 +19,16 @@ # (DevCI), which disables testability and breaks @testable imports in package tests xcargs: "-skipPackagePluginValidation -skipMacroValidation ENABLE_TESTABILITY=YES RUN_IN_CI=#{ENV['RUN_IN_CI']}", output_directory: "./fastlane/test_output/", + result_bundle: true, + buildlog_path: "./fastlane/build_logs/", + include_simulator_logs: true, disable_concurrent_testing: true } - # DEBUG MODE: Helps diagnose build failures (Crashlytics, dependencies, etc.) - # Enables: verbose logs, saves build artifacts, continues on failure - # Use when: normal builds fail with unclear errors + # Raw console output is optional; result bundles and logs are always retained. if debug_mode - UI.important "🔍 Debug mode enabled - verbose logging and artifacts will be collected" - scan_params.merge!({ - buildlog_path: "./fastlane/build_logs/", # Saves xcodebuild logs for analysis - xcpretty_args: "--verbose", # Shows full xcodebuild output - fail_build: false # Continues to collect maximum info even on failure - }) + UI.important "Debug mode enabled - showing raw xcodebuild output" + scan_params[:xcodebuild_formatter] = "" end scan(scan_params) From 651bf27c1631bb3c73856067b0154a43f6cf680c Mon Sep 17 00:00:00 2001 From: w Date: Tue, 29 Sep 2026 15:13:02 -0400 Subject: [PATCH 12/36] refactor(ios): share permission presentation across consent and settings --- .changeset/pvm-host-runtime-authority.md | 3 + .../ProductPermissionPromptViewFactory.swift | 181 ++++-------------- .../AppPermissionsViewModelFactory.swift | 130 ++++++------- 3 files changed, 100 insertions(+), 214 deletions(-) diff --git a/.changeset/pvm-host-runtime-authority.md b/.changeset/pvm-host-runtime-authority.md index 9a86eac72c..d26193ced4 100644 --- a/.changeset/pvm-host-runtime-authority.md +++ b/.changeset/pvm-host-runtime-authority.md @@ -21,3 +21,6 @@ full-source declaration snapshots, while retaining deterministic code generation Preserve incoming-payment ownership and native Coinage ledger records when migrating either the historical Chat store or current main's iOS store to the combined model. Retain both historical model variants for migration detection. + +Centralize iOS permission presentation for consent prompts and app settings, +preserving the separate Chat and genesis-scoped JAM peer disclosures. diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift b/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift index f8f30f03ba..4484b5ce02 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift @@ -72,98 +72,44 @@ private extension ProductPermissionPromptViewFactory { productId: String, permission: ProductPermission ) -> PromptContent { + let title: String + let body: String switch permission { case let .deviceCapability(capability): - PromptContent( - title: String( - localized: .Products.permissionTitleDeviceCapability( - productId: productId, - capability: capabilityDisplayName(capability) - ) - ), - body: capabilityDescription(capability), - icon: iconForCapability(capability) - ) - case let .networkAccess(domain): - PromptContent( - title: String( - localized: .Products.permissionTitleNetworkAccess( - productId: productId - ) - ), - body: String( - localized: .Products.permissionBodyNetworkAccess(domain: domain) - ), - icon: makeIcon(systemName: "globe") - ) - case let .accountAccess(targetProductId): - PromptContent( - title: String( - localized: .Products.permissionTitleAccountAccess( - productId: productId - ) - ), - body: String( - localized: .Products.permissionBodyAccountAccess( - targetProductId: targetProductId - ) - ), - icon: makeIcon(systemName: "person.crop.circle") - ) - case .balanceAccess: - PromptContent( - title: String(localized: .Products.permissionTitleRemote(productId: productId)), - body: String(localized: .Products.permissionBodyBalanceAccess), - icon: makeIcon(systemName: "dollarsign.circle.fill") - ) - case .webRtcAccess: - PromptContent( - title: String(localized: .Products.permissionTitleRemote(productId: productId)), - body: String(localized: .Products.permissionBodyWebRtc), - icon: makeIcon(systemName: "video.fill") - ) - case .chainSubmitAccess: - PromptContent( - title: String(localized: .Products.permissionTitleRemote(productId: productId)), - body: String(localized: .Products.permissionBodyChainSubmit), - icon: makeIcon(systemName: "link") - ) - case .preimageSubmitAccess: - PromptContent( - title: String(localized: .Products.permissionTitleRemote(productId: productId)), - body: String(localized: .Products.permissionBodyPreimageSubmit), - icon: makeIcon(systemName: "doc.text") - ) - case .statementSubmitAccess: - PromptContent( - title: String(localized: .Products.permissionTitleRemote(productId: productId)), - body: String(localized: .Products.permissionBodyStatementSubmit), - icon: makeIcon(systemName: "text.bubble") + title = String( + localized: .Products.permissionTitleDeviceCapability( + productId: productId, + capability: capabilityDisplayName(capability) + ) ) + body = permission.permissionDescription + case .networkAccess: + title = String(localized: .Products.permissionTitleNetworkAccess(productId: productId)) + body = permission.permissionDescription + case .accountAccess: + title = String(localized: .Products.permissionTitleAccountAccess(productId: productId)) + body = permission.permissionDescription + case .balanceAccess, + .webRtcAccess, + .chainSubmitAccess, + .preimageSubmitAccess, + .statementSubmitAccess, + .userIdentityAccess: + title = String(localized: .Products.permissionTitleRemote(productId: productId)) + body = permission.permissionDescription case let .jamPeersAccess(genesis): - PromptContent( - title: String( - localized: .Products.permissionTitleJamPeers( - productId: productId, - shortGenesis: ProductPermission.shortGenesis(genesis) - ) - ), - body: String(localized: .Products.permissionBodyJamPeers), - icon: makeIcon(systemName: "point.3.connected.trianglepath.dotted") - ) - case .userIdentityAccess: - PromptContent( - title: String(localized: .Products.permissionTitleRemote(productId: productId)), - body: String(localized: .Products.permissionBodyUserIdentityAccess), - icon: makeIcon(systemName: "person.text.rectangle") + title = String( + localized: .Products.permissionTitleJamPeers( + productId: productId, + shortGenesis: ProductPermission.shortGenesis(genesis) + ) ) + body = String(localized: .Products.permissionBodyJamPeers) case .chatAuthority: - PromptContent( - title: String(localized: .Products.permissionTitleChatAuthority(productId: productId)), - body: String(localized: .Products.permissionBodyChatAuthority), - icon: makeIcon(systemName: "message.badge.shield") - ) + title = String(localized: .Products.permissionTitleChatAuthority(productId: productId)) + body = permission.permissionDescription } + return PromptContent(title: title, body: body, icon: makeIcon(systemName: permission.permissionIconSystemName)) } static func makeBatchedContent( @@ -180,40 +126,7 @@ private extension ProductPermissionPromptViewFactory { } static func permissionDescription(for permission: ProductPermission) -> String { - switch permission { - case let .networkAccess(domain): - "- " + String( - localized: .Products.permissionBodyNetworkAccess(domain: domain) - ) - case .balanceAccess: - "- " + String(localized: .Products.permissionBodyBalanceAccess) - case .webRtcAccess: - "- " + String(localized: .Products.permissionBodyWebRtc) - case .chainSubmitAccess: - "- " + String(localized: .Products.permissionBodyChainSubmit) - case .preimageSubmitAccess: - "- " + String(localized: .Products.permissionBodyPreimageSubmit) - case .statementSubmitAccess: - "- " + String(localized: .Products.permissionBodyStatementSubmit) - case let .jamPeersAccess(genesis): - "- " + String( - localized: .Products.permissionLabelJamPeers( - shortGenesis: ProductPermission.shortGenesis(genesis) - ) - ) - case let .deviceCapability(capability): - "- " + capabilityDescription(capability) - case let .accountAccess(targetProductId): - "- " + String( - localized: .Products.permissionBodyAccountAccess( - targetProductId: targetProductId - ) - ) - case .userIdentityAccess: - "- " + String(localized: .Products.permissionBodyUserIdentityAccess) - case .chatAuthority: - "- " + String(localized: .Products.permissionBodyChatAuthority) - } + "- " + permission.permissionDescription } static func makeAction( @@ -240,36 +153,6 @@ private extension ProductPermissionPromptViewFactory { } } - static func capabilityDescription(_ capability: DeviceCapabilityType) -> String { - switch capability { - case .notifications: String(localized: .Products.permissionCapabilityDescriptionNotifications) - case .camera: String(localized: .Products.permissionCapabilityDescriptionCamera) - case .microphone: String(localized: .Products.permissionCapabilityDescriptionMicrophone) - case .bluetooth: String(localized: .Products.permissionCapabilityDescriptionBluetooth) - case .nfc: String(localized: .Products.permissionCapabilityDescriptionNfc) - case .location: String(localized: .Products.permissionCapabilityDescriptionLocation) - case .clipboard: String(localized: .Products.permissionCapabilityDescriptionClipboard) - case .openUrl: String(localized: .Products.permissionCapabilityDescriptionOpenUrl) - case .biometrics: String(localized: .Products.permissionCapabilityDescriptionBiometrics) - } - } - - static func iconForCapability(_ capability: DeviceCapabilityType) -> UIImage? { - let name = - switch capability { - case .notifications: "bell.fill" - case .camera: "camera.fill" - case .microphone: "mic.fill" - case .bluetooth: "antenna.radiowaves.left.and.right" - case .nfc: "wave.3.right" - case .location: "location.fill" - case .clipboard: "doc.on.clipboard.fill" - case .openUrl: "safari.fill" - case .biometrics: "faceid" - } - return makeIcon(systemName: name) - } - static func makeIcon(systemName: String) -> UIImage? { let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .regular) return UIImage(systemName: systemName, withConfiguration: config)? diff --git a/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift b/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift index c50d2fd3b3..08597fa955 100644 --- a/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift @@ -19,86 +19,72 @@ extension AppPermissionsViewModelFactory: AppPermissionsViewModelMaking { pendingDeletionIds: Set ) -> [AppPermissionsViewLayout.Item] { grants.map { grant in - let display = displayInfo(for: grant.permission) let isOn = !pendingDeletionIds.contains(grant.identifier) return AppPermissionsViewLayout.Item( id: grant.identifier, - title: display.title, - description: display.description, + title: grant.permission.settingsTitle, + description: grant.permission.permissionDescription, isOn: isOn ) } } } -private extension AppPermissionsViewModelFactory { - typealias DisplayInfo = (title: String, description: String) - - func displayInfo(for permission: ProductPermission) -> DisplayInfo { - switch permission { - case let .deviceCapability(capability): - (capabilityTitle(capability), capabilityDescription(capability)) +/// Shared descriptions keep consent prompts and the revocation screen in agreement. +extension ProductPermission { + var permissionDescription: String { + switch self { + case let .deviceCapability(capability): capabilityDescription(capability) case let .networkAccess(domain): - ( - String(localized: .Products.appPermissionNetworkTitle), - String(localized: .Products.permissionBodyNetworkAccess(domain: domain)) - ) + String(localized: .Products.permissionBodyNetworkAccess(domain: domain)) case let .accountAccess(targetProductId): - ( - String(localized: .Products.appPermissionAccountTitle), - String( - localized: .Products.permissionBodyAccountAccess( - targetProductId: targetProductId - ) - ) - ) - case .balanceAccess: - ( - String(localized: .Products.appPermissionBalanceTitle), - String(localized: .Products.permissionBodyBalanceAccess) - ) - case .webRtcAccess: - ( - String(localized: .Products.appPermissionWebRtcTitle), - String(localized: .Products.permissionBodyWebRtc) - ) - case .chainSubmitAccess: - ( - String(localized: .Products.appPermissionChainSubmitTitle), - String(localized: .Products.permissionBodyChainSubmit) - ) - case .preimageSubmitAccess: - ( - String(localized: .Products.appPermissionPreimageSubmitTitle), - String(localized: .Products.permissionBodyPreimageSubmit) - ) - case .statementSubmitAccess: - ( - String(localized: .Products.appPermissionStatementSubmitTitle), - String(localized: .Products.permissionBodyStatementSubmit) - ) + String(localized: .Products.permissionBodyAccountAccess(targetProductId: targetProductId)) + case .balanceAccess: String(localized: .Products.permissionBodyBalanceAccess) + case .webRtcAccess: String(localized: .Products.permissionBodyWebRtc) + case .chainSubmitAccess: String(localized: .Products.permissionBodyChainSubmit) + case .preimageSubmitAccess: String(localized: .Products.permissionBodyPreimageSubmit) + case .statementSubmitAccess: String(localized: .Products.permissionBodyStatementSubmit) case let .jamPeersAccess(genesis): - ( - String(localized: .Products.appPermissionJamPeersTitle), - String( - localized: .Products.permissionLabelJamPeers( - shortGenesis: ProductPermission.shortGenesis(genesis) - ) - ) - ) - case .userIdentityAccess: - ( - String(localized: .Products.appPermissionUserIdentityTitle), - String(localized: .Products.permissionBodyUserIdentityAccess) - ) - case .chatAuthority: - ( - String(localized: .Products.appPermissionChatAuthorityTitle), - String(localized: .Products.permissionBodyChatAuthority) - ) + String(localized: .Products.permissionLabelJamPeers(shortGenesis: ProductPermission.shortGenesis(genesis))) + case .userIdentityAccess: String(localized: .Products.permissionBodyUserIdentityAccess) + case .chatAuthority: String(localized: .Products.permissionBodyChatAuthority) } } + var permissionIconSystemName: String { + switch self { + case let .deviceCapability(capability): capabilityIcon(capability) + case .networkAccess: "globe" + case .accountAccess: "person.crop.circle" + case .balanceAccess: "dollarsign.circle.fill" + case .webRtcAccess: "video.fill" + case .chainSubmitAccess: "link" + case .preimageSubmitAccess: "doc.text" + case .statementSubmitAccess: "text.bubble" + case .jamPeersAccess: "point.3.connected.trianglepath.dotted" + case .userIdentityAccess: "person.text.rectangle" + case .chatAuthority: "message.badge.shield" + } + } + + var settingsTitle: String { + switch self { + case let .deviceCapability(capability): capabilityTitle(capability) + case .networkAccess: String(localized: .Products.appPermissionNetworkTitle) + case .accountAccess: String(localized: .Products.appPermissionAccountTitle) + case .balanceAccess: String(localized: .Products.appPermissionBalanceTitle) + case .webRtcAccess: String(localized: .Products.appPermissionWebRtcTitle) + case .chainSubmitAccess: String(localized: .Products.appPermissionChainSubmitTitle) + case .preimageSubmitAccess: String(localized: .Products.appPermissionPreimageSubmitTitle) + case .statementSubmitAccess: String(localized: .Products.appPermissionStatementSubmitTitle) + case .jamPeersAccess: String(localized: .Products.appPermissionJamPeersTitle) + case .userIdentityAccess: String(localized: .Products.appPermissionUserIdentityTitle) + case .chatAuthority: String(localized: .Products.appPermissionChatAuthorityTitle) + } + } +} + +private extension ProductPermission { func capabilityTitle(_ capability: DeviceCapabilityType) -> String { switch capability { case .notifications: String(localized: .Products.appPermissionCapabilityNotifications) @@ -126,4 +112,18 @@ private extension AppPermissionsViewModelFactory { case .biometrics: String(localized: .Products.permissionCapabilityDescriptionBiometrics) } } + + func capabilityIcon(_ capability: DeviceCapabilityType) -> String { + switch capability { + case .notifications: "bell.fill" + case .camera: "camera.fill" + case .microphone: "mic.fill" + case .bluetooth: "antenna.radiowaves.left.and.right" + case .nfc: "wave.3.right" + case .location: "location.fill" + case .clipboard: "doc.on.clipboard.fill" + case .openUrl: "safari.fill" + case .biometrics: "faceid" + } + } } From 51caee80a9fd742a28c764456646d58a096034d1 Mon Sep 17 00:00:00 2001 From: w Date: Thu, 1 Oct 2026 00:03:59 -0400 Subject: [PATCH 13/36] Scope web product callbacks to their execution on a shared native host --- js/packages/truapi-host/README.md | 131 +++++++++--------- js/packages/truapi-host/src/wasm-module.ts | 1 + .../src/web/create-worker-host-runtime.ts | 78 ++++++++--- .../src/web/worker-provider.test.ts | 96 ++++++++++++- .../truapi-host/src/worker-protocol.ts | 9 +- js/packages/truapi-host/src/worker-runtime.ts | 31 ++++- rust/crates/truapi/src/host_core.rs | 34 +++-- rust/crates/truapi/src/native/runtime.rs | 7 +- rust/crates/truapi/src/runtime.rs | 8 +- rust/crates/truapi/src/wasm.rs | 41 +++++- 10 files changed, 325 insertions(+), 111 deletions(-) diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 9b13c2f1a4..3806401d05 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -1,8 +1,7 @@ # @parity/truapi-host -WASM-backed TrUAPI host runtime. It embeds the `truapi` Rust core (compiled to WASM) -behind a Web Worker provider, plus per-environment integration entry points. It is the -counterpart to the native Android/iOS host shells. +WASM-backed TrUAPI host runtime. It embeds the `truapi` Rust core (compiled to WASM) behind a Web Worker provider, plus +per-environment integration entry points. It is the counterpart to the native Android/iOS host shells. ## Entry points @@ -26,13 +25,25 @@ The package exposes tree-shakeable subpath exports — import only what your env host and excludes `WasmSigningHostRuntime`; `wasm/testing` adds the Rust `wasm-signing-host` and `test-host` features, which is what lets the test host hold keys and answer resource allocation as granted without allocating anything. A real browser wallet instead needs a web bundle built with `--no-default-features --features wasm-signing-host`, without -`test-host`. That enables native signing and wallet administration without the testing-only allocation shortcuts. -Build that wallet variant with `npm run build:wasm -- --web-only --signing-host`. -The default web build remains pairing-only. -Run the package tests against the default web/testing build, before selecting the wallet variant for consumer verification. -`ProductRuntimeConfig` configures the pairing host and requires no network suffix. The signing constructor's -configuration requires `runtimeConfig.networkSuffix` in addition: the bare TLD (`dot`, `paseo`, or `testnet`) matching -the People chain and the wallet's onboarding configuration. +`test-host`. That enables native signing and wallet administration without the testing-only allocation shortcuts. Build +that wallet variant with `npm run build:wasm -- --web-only --signing-host`. The default web build remains pairing-only. +Run the package tests against the default web/testing build, before selecting the wallet variant for consumer +verification. `ProductRuntimeConfig` configures the pairing host and requires no network suffix. The signing +constructor's configuration requires `runtimeConfig.networkSuffix` in addition: the bare TLD (`dot`, `paseo`, or +`testnet`) matching the People chain and the wallet's onboarding configuration. + +`runtime.createProvider(product, callbacks?)` optionally binds platform callbacks to one product execution while +retaining the same shared native host. Omit the second argument to use the host's default callbacks. Pass a complete +`WebWorkerHostCallbacks` bundle (not a partial override) when each iframe or worker connection owns its own consent UI. +Dispose that UI scope when the connection closes, provider creation fails, or the whole host retires; a replacement +connection must get a fresh scope. Provider disposal, frame failure, failed creation, and host teardown remove the SDK's +callback route, so stale requests cannot fall through to another connection's callbacks. + +Shared authentication, core storage, chain transport, and signing-wallet authority remain owned by the native host; the +optional bundle does not replace them. Raw Wasm consumers can use +`productRuntime(product, coreCallbacks, platformCallbacks?)` for the same execution-local adapters. This is callback +routing isolation, not per-call `AbortSignal` cancellation: hosts must still retire their connection-owned interactive +UI explicitly. `runtimeConfig.assetHub` is required by both configurations, pairing and signing. It is the Asset Hub genesis hash, in the same shape as `runtimeConfig.people` and `runtimeConfig.bulletin`. Product manifests are read from the dotNS @@ -248,15 +259,12 @@ The index crosses as a SCALE-encoded `DerivationIndex`, the same value a review code behind it stays core-owned and a host never reconstructs it. `productAccountAddress` applies the prefix host-spec C.6 fixes, rather than leaving each host to choose one. -`contacts` needs both callbacks, or the group counts as absent. `pickContact` -draws the picker and returns the chosen account, or `NoContacts` when there is -nobody to show. `contacts({ handleKey, handles })` resolves the handles a -transaction names: one entry per handle, in order, the account or `undefined`. -A contact's handle is BLAKE2b-256 keyed with `handleKey` over its 32-byte -account (`blake2b(account, { key: handleKey, dkLen: 32 })` in `@noble/hashes`). -The core re-checks every account returned. It caches what it resolves, so call -`notifyContactsChanged()` whenever a contact is removed or blocked. Omit blocked -contacts from both. See the contacts RFC (`docs/rfcs/contacts-api.md`). +`contacts` needs both callbacks, or the group counts as absent. `pickContact` draws the picker and returns the chosen +account, or `NoContacts` when there is nobody to show. `contacts({ handleKey, handles })` resolves the handles a +transaction names: one entry per handle, in order, the account or `undefined`. A contact's handle is BLAKE2b-256 keyed +with `handleKey` over its 32-byte account (`blake2b(account, { key: handleKey, dkLen: 32 })` in `@noble/hashes`). The +core re-checks every account returned. It caches what it resolves, so call `notifyContactsChanged()` whenever a contact +is removed or blocked. Omit blocked contacts from both. See the contacts RFC (`docs/rfcs/contacts-api.md`). ## Generated WASM artefacts @@ -265,9 +273,9 @@ through `chainConnect`; if they omit it, chain calls fail with the core's standa the workspace size-optimized Rust profile plus `wasm-opt -Oz`, validate that debug/name/producers custom sections were stripped, and emit `.wasm.gz` and `.wasm.br` sidecars for hosts that serve precompressed assets. -The core stays an `rlib` for Rust/no_std consumers. This build explicitly requests a `cdylib` with -`cargo rustc`, then runs `wasm-bindgen` and `wasm-opt` using the profile settings in the core's Cargo metadata. -The separate `truapi-verifiable` module still builds with `wasm-pack`; its optimized hash is embedded into both cores. +The core stays an `rlib` for Rust/no_std consumers. This build explicitly requests a `cdylib` with `cargo rustc`, then +runs `wasm-bindgen` and `wasm-opt` using the profile settings in the core's Cargo metadata. The separate +`truapi-verifiable` module still builds with `wasm-pack`; its optimized hash is embedded into both cores. `TRUAPI_WASM_PROFILE` accepts `release` (default), `dev`, or `profiling`, with the same profile behavior as wasm-pack. Prerequisites on PATH: @@ -275,17 +283,17 @@ Prerequisites on PATH: - Rust with `rustup target add wasm32-unknown-unknown`. - `wasm-pack` 0.14.0 (`cargo install wasm-pack --version 0.14.0 --locked`). - `wasm-bindgen-cli` at the exact resolved `wasm-bindgen` version in `Cargo.lock` - (`cargo install wasm-bindgen-cli --version --locked --force`). - The build rejects a mismatched CLI and prints the required install command. + (`cargo install wasm-bindgen-cli --version --locked --force`). The build rejects a mismatched CLI + and prints the required install command. - Binaryen 117's `wasm-opt`, matching wasm-pack 0.14.0's optimizer. Download the appropriate - [Binaryen version_117 archive](https://github.com/WebAssembly/binaryen/releases/tag/version_117) - and add its `bin` directory to PATH. + [Binaryen version_117 archive](https://github.com/WebAssembly/binaryen/releases/tag/version_117) and add its `bin` + directory to PATH. -From the repository root, `bash scripts/install-wasm-artifact-tools.sh` installs -the matching bindgen CLI and checksum-verified Binaryen archive, then prints the -directory to add to PATH. CI uses the same installer. +From the repository root, `bash scripts/install-wasm-artifact-tools.sh` installs the matching bindgen CLI and +checksum-verified Binaryen archive, then prints the directory to add to PATH. CI uses the same installer. -Build after editing `rust/crates/truapi` and before packaging, publishing, or running tests that load the raw WASM bundle: +Build after editing `rust/crates/truapi` and before packaging, publishing, or running tests that load the raw WASM +bundle: ```bash npm run build:wasm # or `make wasm` from the repo root @@ -381,18 +389,14 @@ once, after the last. A `wanted: false` is permission to stop, not an order: a h ## Debugging (dev-only) -The worker can stream every product↔core wire frame to the wire debugger. It is -off by default and the embedding host decides; the product needs no changes. -Two conditions must **both** hold or nothing dials and the core installs no tap: +The worker can stream every product↔core wire frame to the wire debugger. It is off by default and the embedding host +decides; the product needs no changes. Two conditions must **both** hold or nothing dials and the core installs no tap: -1. **The host page is a dev build.** The dial sits behind a hard - `import.meta.env.DEV` gate, which bundlers replace with a boolean literal: in - a production bundle that gate is false, so no option can turn the tap on. A - production build that shows no frames is this gate, not a broken debugger. - `NODE_ENV=development` is what opens the gate under Vite. -2. **A `ws://` loopback URL reaches the runtime**, from one of two places. The - host's own value wins over the build's, so the build's is a default and never - an override: +1. **The host page is a dev build.** The dial sits behind a hard `import.meta.env.DEV` gate, which bundlers replace with + a boolean literal: in a production bundle that gate is false, so no option can turn the tap on. A production build + that shows no frames is this gate, not a broken debugger. `NODE_ENV=development` is what opens the gate under Vite. +2. **A `ws://` loopback URL reaches the runtime**, from one of two places. The host's own value wins over the build's, + so the build's is a default and never an override: ```ts // 1. the host passes it, the normal path, where the host stays in control @@ -408,31 +412,26 @@ Two conditions must **both** hold or nothing dials and the core installs no tap: VITE_TRUAPI_DEBUGGER_URL=ws://127.0.0.1:9231 vite build ``` - Passing `null` or `""` is how a host refuses the dial even when the build - carries one; omitting the field takes the build's value. - - While a dial is live the host shows a small fixed-position badge naming every - endpoint frames are going to, so a tap left on from an earlier session is - visible rather than buried in a console line. Pass `debuggerIndicator: false` - to suppress it, and only when the host renders its own signal, since the - point is that a host streaming frames is never silent about it. One runtime - suppressing the badge leaves another runtime's badge alone. - - The dial is resolved once, when the runtime is created, and cannot be changed - from the page afterwards, so whether this session is observed is a property - of the build and the host, not of anything typed into a console later. - -Run the debugger at the other end (`@parity/truapi-debugger`, `npm run serve`, -`127.0.0.1:9231`). On the next runtime boot the worker dials that URL and (via -the Rust core's `DebugSink` tap) sends each frame as `{ channelId, dir, frame }`. - -The URL must be `ws://` on a loopback host. Anything else — `wss://`, `http://`, -a LAN or public address, a non-loopback hostname — yields an inert link and a -`wire debugger URL rejected` console warning; there is no certificate or `wss` -path. Prefer the literal `127.0.0.1` over `localhost`: `localhost` passes the -gate, but it resolves `::1` first on macOS while the debugger binds `127.0.0.1` -alone, so the same URL handed to a native host (`truapi`'s `WsDebugSink` -dials the first resolved address) silently never connects. + Passing `null` or `""` is how a host refuses the dial even when the build carries one; omitting the field takes the + build's value. + + While a dial is live the host shows a small fixed-position badge naming every endpoint frames are going to, so a tap + left on from an earlier session is visible rather than buried in a console line. Pass `debuggerIndicator: false` to + suppress it, and only when the host renders its own signal, since the point is that a host streaming frames is never + silent about it. One runtime suppressing the badge leaves another runtime's badge alone. + + The dial is resolved once, when the runtime is created, and cannot be changed from the page afterwards, so whether + this session is observed is a property of the build and the host, not of anything typed into a console later. + +Run the debugger at the other end (`@parity/truapi-debugger`, `npm run serve`, `127.0.0.1:9231`). On the next runtime +boot the worker dials that URL and (via the Rust core's `DebugSink` tap) sends each frame as +`{ channelId, dir, frame }`. + +The URL must be `ws://` on a loopback host. Anything else — `wss://`, `http://`, a LAN or public address, a non-loopback +hostname — yields an inert link and a `wire debugger URL rejected` console warning; there is no certificate or `wss` +path. Prefer the literal `127.0.0.1` over `localhost`: `localhost` passes the gate, but it resolves `::1` first on macOS +while the debugger binds `127.0.0.1` alone, so the same URL handed to a native host (`truapi`'s `WsDebugSink` dials the +first resolved address) silently never connects. The debugger owns all decoding and decodes every frame it can, including signing and payment payloads; its safety is the dev-build gate above, not redaction. See `js/packages/truapi-debugger/README.md` for the tap, the envelope, and the diff --git a/js/packages/truapi-host/src/wasm-module.ts b/js/packages/truapi-host/src/wasm-module.ts index 550999cfed..70404345c1 100644 --- a/js/packages/truapi-host/src/wasm-module.ts +++ b/js/packages/truapi-host/src/wasm-module.ts @@ -52,6 +52,7 @@ export interface WorkerHostRuntime extends PermissionAuthorizationRuntime { productRuntime( product: unknown, coreCallbacks: unknown, + platformCallbacks?: unknown, ): WorkerProductRuntime; disconnectSession(): Promise; notifyContactsChanged(): void; diff --git a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts index 9f6fa50b05..5636ae5012 100644 --- a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts +++ b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts @@ -67,10 +67,13 @@ export interface WorkerPairingHostRuntime { * should. Undefined for a core built before the export existed. */ readonly coreWireSchemaHash: string | undefined; - createProvider(product: { - productId: string; - executionKind?: ProductExecutionKind; - }): Promise; + createProvider( + product: { + productId: string; + executionKind?: ProductExecutionKind; + }, + callbacks?: WebWorkerHostCallbacks, + ): Promise; disconnectSession(): Promise; cancelPairing(): void; notifySessionStoreChanged(): void; @@ -207,6 +210,7 @@ interface RuntimeState { identityGeneration: number; pendingAllowanceSnapshots: Map>; rawCallbacks: RawCallbacks; + coreCallbacks: Map; cores: Map; pendingCores: Map< number, @@ -219,9 +223,9 @@ interface RuntimeState { subscriptionDisposers: Map void>; /** * Open `worker.beginOperation` holds. A non-empty set defers `dispose()`. - * Worker-wide rather than per-core, since a `callbackRequest` carries no core - * id, so entries are product-scoped: `OperationId` is only unique per product - * and two products sharing this worker may be handed the same id. + * Worker-wide rather than per-core: operation holds can outlive a product + * connection. Entries are product-scoped because `OperationId` is only + * unique per product and products sharing this worker may use the same id. */ openOperations: Set; /** A dispose() arrived while operations were open; run it once they drain. */ @@ -578,18 +582,25 @@ function handleCallbackRequest( state: RuntimeState, msg: { requestId: number; + coreId?: number; name: CallbackName; args: readonly unknown[]; }, ): void { - const fn = Object.hasOwn(state.rawCallbacks, msg.name) - ? ( - state.rawCallbacks as unknown as Record< - string, - (...args: readonly unknown[]) => unknown - > - )[msg.name] - : undefined; + if (state.disposed) return; + const callbacks = + msg.coreId === undefined + ? state.rawCallbacks + : state.coreCallbacks.get(msg.coreId); + const fn = + callbacks && Object.hasOwn(callbacks, msg.name) + ? ( + callbacks as unknown as Record< + string, + (...args: readonly unknown[]) => unknown + > + )[msg.name] + : undefined; if (!fn) { state.worker.postMessage({ kind: "callbackResponse", @@ -600,7 +611,12 @@ function handleCallbackRequest( return; } Promise.resolve() - .then(() => fn(...msg.args)) + .then(() => { + if (state.disposed) throw new Error("Host runtime is unavailable"); + if (msg.coreId !== undefined && !state.coreCallbacks.has(msg.coreId)) + throw new Error("Product callbacks are unavailable"); + return fn(...msg.args); + }) .then( (value) => { // Tracked in the success arm only: a rejected begin must not leave a @@ -642,6 +658,7 @@ function handleSubscriptionStart( state: RuntimeState, msg: { subId: number; + coreId?: number; name: SubscriptionName; payload: Uint8Array | string | null; }, @@ -664,15 +681,20 @@ function handleSubscriptionStart( }; let dispose: (() => void) | void = undefined; try { + const callbacks = + msg.coreId === undefined + ? state.rawCallbacks + : state.coreCallbacks.get(msg.coreId); + if (!callbacks) throw new Error("Product callbacks are unavailable"); dispose = startRawSubscription( - state.rawCallbacks, + callbacks, msg.name, msg.payload, sendItem, sendError, ); } catch (err) { - console.error(`[truapi worker] ${msg.name} threw on start:`, err); + sendError({ reason: errorMessage(err) }); return; } if (typeof dispose === "function") { @@ -1110,6 +1132,7 @@ function teardown(state: RuntimeState, error: Error, fault: boolean): void { closeCoreState(core, error); } state.cores.clear(); + state.coreCallbacks.clear(); for (const fn of state.subscriptionDisposers.values()) { try { fn(); @@ -1234,6 +1257,7 @@ function createWebWorkerHostRuntime( identityGeneration: 0, pendingAllowanceSnapshots: new Map(), rawCallbacks: callbacks, + coreCallbacks: new Map(), cores: new Map(), pendingCores: new Map(), subscriptionDisposers: new Map(), @@ -1563,6 +1587,7 @@ function handleCoreError( const pending = state.pendingCores.get(coreId); if (!pending) return; state.pendingCores.delete(coreId); + state.coreCallbacks.delete(coreId); pending.reject(new Error(error)); } @@ -1577,6 +1602,7 @@ function handleFrameError( const failure = new Error(`worker frame error: ${error}`); closeCoreState(core, failure); state.cores.delete(coreId); + state.coreCallbacks.delete(coreId); // Renders left registered would never settle: the worker cancels them with // the core, so nothing further arrives to complete the sink. failRendersForCore(state, coreId, failure); @@ -1595,7 +1621,7 @@ function buildRuntime( ): WorkerPairingHostRuntime & WorkerSigningHostRuntime { const runtime: WorkerPairingHostRuntime & WorkerSigningHostRuntime = { coreWireSchemaHash: state.coreWireSchemaHash, - createProvider(product): Promise { + createProvider(product, callbacks): Promise { if (state.disposed) { return Promise.reject( state.closedError ?? new Error("runtime disposed"), @@ -1609,13 +1635,26 @@ function buildRuntime( reject, }); try { + if (callbacks) + state.coreCallbacks.set(coreId, createWasmRawCallbacks(callbacks)); state.worker.postMessage({ kind: "createCore", coreId, product, + ...(callbacks === undefined + ? {} + : { + capabilities: { + chat: callbacks.chat !== undefined, + contacts: callbacks.contacts !== undefined, + permissionStatus: callbacks.permissionStatus !== undefined, + pocket: callbacks.pocket !== undefined, + }, + }), } satisfies MainToWorker); } catch (err) { state.pendingCores.delete(coreId); + state.coreCallbacks.delete(coreId); reject(err instanceof Error ? err : new Error(String(err))); } }); @@ -2114,6 +2153,7 @@ function buildProvider( if (core.disposed) return; closeCoreState(core, new Error("provider disposed")); state.cores.delete(core.coreId); + state.coreCallbacks.delete(core.coreId); // Renders left registered would never settle: the worker cancels them // with the core, so nothing further arrives to complete the sink. failRendersForCore(state, core.coreId, new Error("provider disposed")); diff --git a/js/packages/truapi-host/src/web/worker-provider.test.ts b/js/packages/truapi-host/src/web/worker-provider.test.ts index c01ad24640..b008cd2c97 100644 --- a/js/packages/truapi-host/src/web/worker-provider.test.ts +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -148,6 +148,98 @@ const devGlobal = globalThis as typeof globalThis & { }; describe("createWebWorkerPairingHostRuntime", () => { + it("isolates interactive callbacks across replacement connections on one host", async () => { + const worker = new FakeWorker(); + const deliveries: string[] = []; + const callbacks = (owner: string) => + makeHostCallbacks({ + notifications: { + pushNotification: async (request) => { + deliveries.push(`${owner}:${request.text}`); + return { id: 42 }; + }, + }, + }); + const runtimePromise = createWebWorkerPairingHostRuntime( + asWorker(worker), + callbacks("host"), + { hostConfig: hostConfigFromRuntimeConfig(runtimeConfig()) }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const runtime = await runtimePromise; + const old = await finishProviderReady( + worker, + runtime.createProvider({ productId: "test.dot" }, callbacks("old")), + ); + const oldId = lastMessageOfKind(worker, "createCore").coreId; + const replacement = await finishProviderReady( + worker, + runtime.createProvider( + { productId: "test.dot" }, + callbacks("replacement"), + ), + ); + const replacementId = lastMessageOfKind(worker, "createCore").coreId; + let requestId = 0; + const notify = async (coreId?: unknown) => { + worker.emit({ + kind: "callbackRequest", + requestId: ++requestId, + ...(coreId === undefined ? {} : { coreId }), + name: "pushNotification", + args: [ + HostPushNotificationRequest.enc({ + text: "Hello!", + deeplink: undefined, + scheduledAt: undefined, + }), + ], + }); + await settle(); + return lastMessageOfKind(worker, "callbackResponse"); + }; + expect((await notify(oldId)).ok).toBe(true); + expect((await notify(replacementId)).ok).toBe(true); + old.dispose(); + expect((await notify(oldId)).ok).toBe(false); + expect((await notify(replacementId)).ok).toBe(true); + expect((await notify()).ok).toBe(true); + expect(deliveries).toEqual([ + "old:Hello!", + "replacement:Hello!", + "replacement:Hello!", + "host:Hello!", + ]); + worker.emit({ + kind: "frameError", + coreId: replacementId, + error: "connection lost", + }); + expect((await notify(replacementId)).ok).toBe(false); + replacement.dispose(); + const failed = runtime.createProvider( + { productId: "test.dot" }, + callbacks("failed"), + ); + const failedId = lastMessageOfKind(worker, "createCore").coreId; + worker.emit({ + kind: "coreError", + coreId: failedId, + error: "creation failed", + }); + await expect(failed).rejects.toThrow("creation failed"); + expect((await notify(failedId)).ok).toBe(false); + runtime.dispose(); + await notify(replacementId); + expect(deliveries).toEqual([ + "old:Hello!", + "replacement:Hello!", + "replacement:Hello!", + "host:Hello!", + ]); + }); + it("initializes the worker without a callback manifest", async () => { const worker = new FakeWorker(); const config = runtimeConfig(); @@ -1451,9 +1543,7 @@ describe("createWebWorkerPairingHostRuntime", () => { initTimeoutMs: 20, }, ); - const initErrorPromise = providerPromise.catch( - (error: unknown) => error, - ); + const initErrorPromise = providerPromise.catch((error: unknown) => error); jest.advanceTimersByTime(20); const initError = await initErrorPromise; diff --git a/js/packages/truapi-host/src/worker-protocol.ts b/js/packages/truapi-host/src/worker-protocol.ts index b5ed6ef58f..23a427f4f7 100644 --- a/js/packages/truapi-host/src/worker-protocol.ts +++ b/js/packages/truapi-host/src/worker-protocol.ts @@ -94,7 +94,12 @@ export type MainToWorker = */ role?: HostRole; } - | { kind: "createCore"; coreId: number; product: unknown } + | { + kind: "createCore"; + coreId: number; + product: unknown; + capabilities?: OptionalCapabilities; + } | { kind: "disposeCore"; coreId: number } | { kind: "setLogLevel"; level: LogLevel } | { kind: "frame"; coreId: number; bytes: Uint8Array } @@ -385,12 +390,14 @@ export type WorkerToMain = | { kind: "callbackRequest"; requestId: number; + coreId?: number; name: CallbackName; args: CallbackArgs; } | { kind: "subscriptionStart"; subId: number; + coreId?: number; name: SubscriptionName; payload: Uint8Array | string | null; } diff --git a/js/packages/truapi-host/src/worker-runtime.ts b/js/packages/truapi-host/src/worker-runtime.ts index 7e0bea3f91..a77b3a1a33 100644 --- a/js/packages/truapi-host/src/worker-runtime.ts +++ b/js/packages/truapi-host/src/worker-runtime.ts @@ -88,6 +88,7 @@ const chainResponseListeners = new Map void>(); function callbackRequest( name: CallbackName, args: readonly unknown[], + coreId?: number, ): Promise { return new Promise((resolve, reject) => { const requestId = ++nextRequestId; @@ -95,7 +96,13 @@ function callbackRequest( if (r.ok) resolve(r.value); else reject(new Error(r.error)); }); - postToMain({ kind: "callbackRequest", requestId, name, args }); + postToMain({ + kind: "callbackRequest", + requestId, + name, + args, + ...(coreId === undefined ? {} : { coreId }), + }); }); } @@ -104,13 +111,20 @@ function startSubscription( payload: Uint8Array | string | null, sendItem: (value: T) => void, sendError: (error: GenericError) => void, + coreId?: number, ): () => void { const subId = ++nextSubId; subscriptionListeners.set(subId, { sendItem: sendItem as (value: unknown) => void, sendError: (error) => sendError({ reason: error }), }); - postToMain({ kind: "subscriptionStart", subId, name, payload }); + postToMain({ + kind: "subscriptionStart", + subId, + name, + payload, + ...(coreId === undefined ? {} : { coreId }), + }); return () => { subscriptionListeners.delete(subId); postToMain({ kind: "subscriptionStop", subId }); @@ -177,12 +191,16 @@ function chainConnect( } /** Build the host-level callback object passed to the WASM runtime. */ -function buildRawCallbacks(capabilities: OptionalCapabilities) { +function buildRawCallbacks( + capabilities: OptionalCapabilities, + coreId?: number, +) { return { ...createWorkerRawCallbacks( { - callbackRequest, - startSubscription, + callbackRequest: (name, args) => callbackRequest(name, args, coreId), + startSubscription: (name, payload, sendItem, sendError) => + startSubscription(name, payload, sendItem, sendError, coreId), chainConnect, }, capabilities, @@ -811,6 +829,9 @@ ctx.addEventListener("message", (ev: MessageEvent) => { const core = runtime.productRuntime( msg.product, buildCoreCallbacks(msg.coreId), + msg.capabilities === undefined + ? undefined + : buildRawCallbacks(msg.capabilities, msg.coreId), ); cores.set(msg.coreId, core); postToMain({ kind: "coreReady", coreId: msg.coreId }); diff --git a/rust/crates/truapi/src/host_core.rs b/rust/crates/truapi/src/host_core.rs index e084784291..81e9ae95c9 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -28,7 +28,6 @@ use tracing::{instrument, warn}; use truapi::v01; use truapi::{CallContext, CancellationReason}; -use crate::truapi_core::TrUApiCore; use crate::frame::ProtocolMessage; use crate::host_internal::sso_messages::{RemoteMessage, SsoRequestOutcome}; use crate::host_logic::worker::WorkerLedger; @@ -42,6 +41,7 @@ use crate::runtime::{ }; use crate::subscription::{HostInitiatedSubscriptionManager, Spawner}; use crate::transport::Transport; +use crate::truapi_core::TrUApiCore; /// Outgoing frame sink owned by a host adapter. /// @@ -300,6 +300,22 @@ impl PairingHostRuntime { ) } + /// Build one execution with local adapters and shared authentication/services. + pub fn product_runtime_with( + &self, + product: ProductContext, + adapters: ConnectionAdapters, + sink: Arc, + ) -> ProductRuntime { + ProductRuntime::new( + self.services.clone(), + self.pairing_host.clone(), + product, + adapters, + sink, + ) + } + /// Build a product-scoped administration handle from this pairing host. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.product_admin"))] pub fn product_admin(&self, product: ProductContext) -> HostAdmin { @@ -709,9 +725,8 @@ impl SigningHostRuntime { ) } - /// Build one product connection with adapters scoped to one native - /// executable while sharing this runtime's authentication and services. - #[cfg(not(target_arch = "wasm32"))] + /// Build one product connection with execution-local adapters while + /// sharing this runtime's authentication and services. pub fn product_runtime_with( &self, product: ProductContext, @@ -1060,10 +1075,7 @@ impl SigningHostRuntime { /// even while that request is still being answered by another call, and a /// withdrawn request is answered `Ignored`. #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.answer_sso_request"))] - pub async fn answer_sso_request( - &self, - message: RemoteMessage, - ) -> SsoRequestOutcome { + pub async fn answer_sso_request(&self, message: RemoteMessage) -> SsoRequestOutcome { let service = SigningHostSsoService::new(self.signing_host.clone()); match service.answer(message).await { Dispatch::Response(answer) => SsoRequestOutcome::Response { @@ -1175,8 +1187,8 @@ impl SigningHostRuntime { } /// Adapters scoped to one product connection: the platform serving its -/// syscalls, the optional native Chat adapter, and the connection's -/// host-fed action streams. Non-native connections use [`Self::from_services`]. +/// syscalls, optional capability adapters, and the connection's host-fed +/// action streams. Unscoped connections use [`Self::from_services`]. /// /// `pocket_platform` is the same kind of optional adapter for the card /// collection. @@ -1184,6 +1196,7 @@ impl SigningHostRuntime { pub struct ConnectionAdapters { pub platform: Arc, pub chat_platform: Option>, + pub contacts_platform: Option>, /// Live OS permission state for this connection. It travels here rather /// than on the host runtime because a native host builds one platform per /// product execution, so the object that reports OS state has to be the @@ -1202,6 +1215,7 @@ impl ConnectionAdapters { Self { platform: services.platform.clone(), chat_platform: services.chat_platform.clone(), + contacts_platform: services.contacts_platform(), permission_status: services.permission_status_host(), permission_grants: Arc::default(), chat: Arc::new(ActionChannel::chat()), diff --git a/rust/crates/truapi/src/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs index aa0ac1c14d..2c8282b1b6 100644 --- a/rust/crates/truapi/src/native/runtime.rs +++ b/rust/crates/truapi/src/native/runtime.rs @@ -31,15 +31,15 @@ use super::config::{ ProductExecutionConfig, }; use super::errors::HostRejection; -use super::executor::shared_native_executor; use super::events::NativeEventBus; +use super::executor::shared_native_executor; +#[cfg(doc)] +use super::parse_pairing_deeplink; use super::platform::{ CallbackPlatform, ChatCallbackPlatform, ContactsCallbackPlatform, PocketCallbackPlatform, }; #[cfg(doc)] use crate::WorkerTransition; -#[cfg(doc)] -use super::parse_pairing_deeplink; /// Process-owned native TrUAPI runtime shared by all executable connections. #[derive(uniffi::Object)] @@ -608,6 +608,7 @@ impl NativeProductExecution { crate::host_core::ConnectionAdapters { platform: self.platform.clone(), chat_platform: self.chat.clone(), + contacts_platform: None, permission_status: Some(self.permission_status.clone()), permission_grants: self.permission_grants.clone(), chat: self.chat_connection.clone(), diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index 0a7f5acbe3..7ec333ebf6 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -301,6 +301,7 @@ pub struct ProductRuntimeHost { services: Arc, platform: Arc, chat_platform: Option>, + contacts_platform: Option>, /// Live OS permission state for this connection, when the host serves it. permission_status: Option>, /// Permission requests and consuming operations can arrive on different connections. @@ -353,6 +354,7 @@ impl ProductRuntimeHost { services, platform: adapters.platform, chat_platform: adapters.chat_platform, + contacts_platform: adapters.contacts_platform, permission_status: adapters.permission_status, temporary_permissions: adapters.permission_grants, authority, @@ -484,6 +486,7 @@ impl ProductRuntimeHost { services, platform, chat_platform: None, + contacts_platform: None, permission_status: None, temporary_permissions: Arc::default(), authority: pairing_host.clone(), @@ -1417,8 +1420,9 @@ impl ProductRuntimeHost { // A capability the host does not serve is a framework answer; a // missing session is one the product handles. let platform = self - .services - .contacts_platform() + .contacts_platform + .clone() + .or_else(|| self.services.contacts_platform()) .ok_or(CallError::Unsupported)?; let session = self .authority diff --git a/rust/crates/truapi/src/wasm.rs b/rust/crates/truapi/src/wasm.rs index 2ae7c1b457..6ac5c5d329 100644 --- a/rust/crates/truapi/src/wasm.rs +++ b/rust/crates/truapi/src/wasm.rs @@ -868,6 +868,33 @@ fn wasm_platform(bridge: Arc) -> WasmPlatformAdapters { } } +fn connection_adapters_from_js( + callbacks: Option<&JsValue>, +) -> Result, JsValue> { + let Some(callbacks) = callbacks.filter(|value| !value.is_null() && !value.is_undefined()) + else { + return Ok(None); + }; + let WasmPlatformAdapters { + platform, + chat_platform, + contacts_platform, + status_host, + pocket_platform, + } = wasm_platform(Arc::new(JsBridge::from_js(callbacks)?)); + Ok(Some(crate::host_core::ConnectionAdapters { + platform, + chat_platform, + contacts_platform, + permission_status: status_host, + // One-use grants belong to this execution, not the shared host. + permission_grants: Arc::default(), + pocket_platform, + chat: Arc::new(crate::runtime::ActionChannel::chat()), + renderer: Arc::new(crate::runtime::ActionChannel::renderer()), + })) +} + /// Reports every worker demand transition to the host's /// `workerDemandChanged(productId, transition)` callback. struct WasmWorkerDemand { @@ -953,11 +980,13 @@ impl WasmPairingHostRuntime { } /// Build one product-scoped runtime from this pairing host runtime. + /// Optional platform callbacks are execution-local; shared authority stays here. #[wasm_bindgen(js_name = productRuntime)] pub fn product_runtime( &self, product: JsValue, core_callbacks: JsValue, + platform_callbacks: Option, ) -> Result { let product = product_context_from_js(&product)?; let channel = CoreChannel::from_js(&core_callbacks)?; @@ -966,7 +995,10 @@ impl WasmPairingHostRuntime { let sink = Arc::new(WasmFrameSink { emit_frame: SendWrapper::new(channel.emit_frame), }); - let runtime = self.runtime.product_runtime(product, sink); + let runtime = match connection_adapters_from_js(platform_callbacks.as_ref())? { + Some(adapters) => self.runtime.product_runtime_with(product, adapters, sink), + None => self.runtime.product_runtime(product, sink), + }; if let Some(debug_emit) = debug_emit { runtime.set_debug_sink( ChannelId(channel_id), @@ -1252,18 +1284,23 @@ impl WasmSigningHostRuntime { } /// Build one product-scoped runtime from this signing host. + /// Optional platform callbacks are execution-local; shared authority stays here. #[wasm_bindgen(js_name = productRuntime)] pub fn product_runtime( &self, product: JsValue, core_callbacks: JsValue, + platform_callbacks: Option, ) -> Result { let product = product_context_from_js(&product)?; let channel = CoreChannel::from_js(&core_callbacks)?; let sink = Arc::new(WasmFrameSink { emit_frame: SendWrapper::new(channel.emit_frame), }); - let runtime = self.runtime.product_runtime(product, sink); + let runtime = match connection_adapters_from_js(platform_callbacks.as_ref())? { + Some(adapters) => self.runtime.product_runtime_with(product, adapters, sink), + None => self.runtime.product_runtime(product, sink), + }; Ok(WasmProductRuntime::from_parts(runtime, channel.dispose)) } From 7a2d845b5b33c8d30167fbe98d35086d665dfb37 Mon Sep 17 00:00:00 2001 From: w Date: Thu, 1 Oct 2026 16:31:30 -0400 Subject: [PATCH 14/36] Rename native JAM live fixture to JAM-TEST-INSTANCE --- README.md | 5 +++++ .../truapi/src/jam-peer-transport-cert.test.ts | 2 +- rust/crates/truapi/Cargo.toml | 2 +- rust/crates/truapi/RUNTIME.md | 4 ++-- ...am_public_devnet.rs => live_jam_test_instance.rs} | 12 ++++++------ 5 files changed, 15 insertions(+), 10 deletions(-) rename rust/crates/truapi/tests/{live_jam_public_devnet.rs => live_jam_test_instance.rs} (98%) diff --git a/README.md b/README.md index e86400d387..497473bc3f 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,11 @@ composition crate; the base `truapi` remains PolkaVM-free. Browser hosts consume `@parity/polkavm-browser-runtime` directly; browser assets are not shipped from this repository. +The native JAM peer transport's live fixture targets JAM-TEST-INSTANCE. +Run `cargo test -p truapi --features mock --test live_jam_test_instance -- --include-ignored` +with network access to its six validators; see the +[peer transport contract](rust/crates/truapi/RUNTIME.md#jam-peer-transport). + Taking a screenshot opens **Report app issue** wherever the shake-opened Debug menu is, which is every build except the store submission: `DEBUG_TOOLS_ENABLED` on Android, false only for the `release` build type, and `TESTNET_FEATURE` on diff --git a/js/packages/truapi/src/jam-peer-transport-cert.test.ts b/js/packages/truapi/src/jam-peer-transport-cert.test.ts index 85d70f8fe2..8b705c5616 100644 --- a/js/packages/truapi/src/jam-peer-transport-cert.test.ts +++ b/js/packages/truapi/src/jam-peer-transport-cert.test.ts @@ -58,7 +58,7 @@ const PATCHED = [ }, ] as const; -// Certificate a stock `jam-public-devnet` validator served during period 2072. +// Certificate a stock JAM-TEST-INSTANCE validator served during period 2072. const STOCK = { compressed: "03aac17e3833a6679e7064934a6f2a2bc9450d3b2b779a9930102ae16a4f16062e", der2072: diff --git a/rust/crates/truapi/Cargo.toml b/rust/crates/truapi/Cargo.toml index 6bdb5a08ac..6306b3372b 100644 --- a/rust/crates/truapi/Cargo.toml +++ b/rust/crates/truapi/Cargo.toml @@ -204,7 +204,7 @@ name = "jam_peer_transport_contract" required-features = ["runtime"] [[test]] -name = "live_jam_public_devnet" +name = "live_jam_test_instance" required-features = ["mock"] [target.'cfg(target_arch = "wasm32")'.dev-dependencies] diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index 1a90ddb10d..b3cae12f18 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -230,8 +230,8 @@ persisted even when every dial waiting on it has given up. The browser core keeps the trait's `NotGranted` defaults, because its JavaScript session answers trait 111 before frames reach the core. -`cargo test -p truapi --features mock --test live_jam_public_devnet -- --include-ignored` -dials the public JAM devnet through a product runtime. +`cargo test -p truapi --features mock --test live_jam_test_instance -- --include-ignored` +dials JAM-TEST-INSTANCE through a product runtime. ### Core database diff --git a/rust/crates/truapi/tests/live_jam_public_devnet.rs b/rust/crates/truapi/tests/live_jam_test_instance.rs similarity index 98% rename from rust/crates/truapi/tests/live_jam_public_devnet.rs rename to rust/crates/truapi/tests/live_jam_test_instance.rs index 007be5ad03..cbf20ece34 100644 --- a/rust/crates/truapi/tests/live_jam_public_devnet.rs +++ b/rust/crates/truapi/tests/live_jam_test_instance.rs @@ -1,5 +1,5 @@ -//! `JamPeerTransport` through a native product runtime against the public JAM -//! devnet (`jam-public-devnet`, six validators on 51.159.188.61). +//! `JamPeerTransport` through a native product runtime against JAM-TEST-INSTANCE +//! (six validators on 51.159.188.61). //! //! Every call is a product frame: through the generated dispatcher into //! `ProductRuntimeHost`, whose dial asks the platform for @@ -25,11 +25,11 @@ mod common; use common::{test_runtime_config, test_spawner}; const GENESIS: &str = "10c123f02eb6df4c01397d797a112055be691883baa2e82f83b618ed6ce45e46"; -/// `follow.json` slot timing of the devnet. +/// `follow.json` slot timing of JAM-TEST-INSTANCE. const SLOT_EPOCH_UNIX_MS: u64 = 1_735_732_800_000; const SLOT_DURATION_MS: u64 = 6_000; /// Validator UDP ports on 51.159.188.61 and the Ed25519 keys their -/// certificates carry, from the devnet's `bootnodes.json`. +/// certificates carry, from JAM-TEST-INSTANCE's `bootnodes.json`. const BOOTNODES: [(u16, &str); 6] = [ ( 43000, @@ -245,8 +245,8 @@ struct Up0 { } #[test] -#[ignore = "needs network access to the public JAM devnet"] -fn dials_every_public_devnet_validator_and_hears_block_announcements() { +#[ignore = "needs network access to JAM-TEST-INSTANCE"] +fn dials_every_test_instance_validator_and_hears_block_announcements() { let _serial = SERIAL .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); From 877556fd8fa97ebc6f4420fb139bfcd99e9bc679 Mon Sep 17 00:00:00 2001 From: w Date: Thu, 1 Oct 2026 16:44:26 -0400 Subject: [PATCH 15/36] Fix refreshed SQLite dependency lock disambiguation --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index e31e3ea098..d7ae1a5df9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4437,7 +4437,7 @@ version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ - "bitflags", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", From cc54b34bf4e87008953737c028ce6c4dd98dec35 Mon Sep 17 00:00:00 2001 From: w Date: Sun, 4 Oct 2026 08:26:35 -0400 Subject: [PATCH 16/36] feat(notifications): integrate canonical receiving on PVM host stack Apply only receiving delta 62b5f915541e85a19bacb0c9b4f1d648d363d9a3 (based on 19a664581ffa0626adf225f11fb4447c083de588) onto verified trinity-user-agents #1011 head f7ac212cf0e19d6860c4d60298d4d1cf442e1c55. Preserve PVM, Chat, Seity and JamPeerTransport surfaces. Reserve shared receiving storage slot 20, keeping existing slots 13-19 intact. Retain no_std protocol imports, host-api codegen input and existing native/browser callback conventions. Generated artifacts must be rebuilt from this integration; no compilation or device qualification is claimed. --- CHANGELOG.md | 8 + Cargo.lock | 15 + Cargo.toml | 1 + README.md | 10 + .../kotlin/io/parity/truapi/TrUAPIHost.kt | 69 ++ .../Sources/TrUAPIHost/TrUAPIHost.swift | 92 ++ js/packages/truapi-host/README.md | 82 ++ js/packages/truapi-host/package.json | 8 + .../src/browser-receiving-transport.test.ts | 87 ++ .../src/browser-receiving-transport.ts | 115 +++ .../src/browser-receiving-worker.ts | 513 +++++++++++ .../truapi-host/src/browser-receiving.ts | 135 +++ .../src/host-callbacks-adapter.test.ts | 70 ++ js/packages/truapi-host/src/runtime.ts | 77 ++ js/packages/truapi-host/src/test-support.ts | 4 + js/packages/truapi-host/src/wasm-module.ts | 5 +- .../src/wasm/web/truapi_server.d.ts | 1 + .../truapi-host/src/web/create-mock-host.ts | 4 + js/packages/truapi/README.md | 75 ++ js/packages/truapi/package.json | 5 + .../truapi/src/notification-container.ts | 159 ++++ .../truapi/src/notification-envelope.test.ts | 173 ++++ .../truapi/src/notification-envelope.ts | 216 +++++ package-lock.json | 22 +- rust/crates/truapi-codegen/src/platform.rs | 3 + .../truapi-codegen/src/platform_callbacks.rs | 3 + .../truapi-codegen/src/rust/wasm_bridge.rs | 121 ++- .../truapi-codegen/src/ts/host_callbacks.rs | 104 ++- rust/crates/truapi-codegen/tests/emission.rs | 209 +++++ rust/crates/truapi/Cargo.toml | 2 + rust/crates/truapi/src/api/notifications.rs | 116 ++- rust/crates/truapi/src/host_core.rs | 28 +- rust/crates/truapi/src/lib.rs | 6 + rust/crates/truapi/src/native/callbacks.rs | 24 + rust/crates/truapi/src/native/errors.rs | 6 + rust/crates/truapi/src/native/platform.rs | 27 + rust/crates/truapi/src/native/runtime.rs | 73 ++ rust/crates/truapi/src/native/tests.rs | 39 + rust/crates/truapi/src/platform.rs | 89 ++ rust/crates/truapi/src/platform/mock.rs | 1 + rust/crates/truapi/src/runtime.rs | 4 + .../src/runtime/capabilities/platform.rs | 169 +++- .../src/runtime/notification_envelope.rs | 520 ++++++++++++ rust/crates/truapi/src/runtime/receiving.rs | 794 ++++++++++++++++++ .../truapi/src/runtime/receiving/tests.rs | 465 ++++++++++ rust/crates/truapi/src/runtime/services.rs | 6 + rust/crates/truapi/src/test_support.rs | 19 + rust/crates/truapi/src/v01/notifications.rs | 169 ++++ .../truapi/src/versioned/notifications.rs | 14 + rust/crates/truapi/src/wasm.rs | 169 +++- rust/crates/truapi/src/wasm/receiving.rs | 214 +++++ 51 files changed, 5299 insertions(+), 41 deletions(-) create mode 100644 js/packages/truapi-host/src/browser-receiving-transport.test.ts create mode 100644 js/packages/truapi-host/src/browser-receiving-transport.ts create mode 100644 js/packages/truapi-host/src/browser-receiving-worker.ts create mode 100644 js/packages/truapi-host/src/browser-receiving.ts create mode 100644 js/packages/truapi/src/notification-container.ts create mode 100644 js/packages/truapi/src/notification-envelope.test.ts create mode 100644 js/packages/truapi/src/notification-envelope.ts create mode 100644 rust/crates/truapi-codegen/tests/emission.rs create mode 100644 rust/crates/truapi/src/runtime/notification_envelope.rs create mode 100644 rust/crates/truapi/src/runtime/receiving.rs create mode 100644 rust/crates/truapi/src/runtime/receiving/tests.rs create mode 100644 rust/crates/truapi/src/wasm/receiving.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f460f77e..36a5693d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/). - migrate the generic runtime and Rust client to SDK 0.16's scoped wire codec 3; the handshake requires an exact codec match, so product guests built against an earlier codec must be rebuilt +- Code-generation verification checks deterministic protocol and host output instead of pinning implementation-text snapshots; generated bindings are compiled and exercised against the runtime. ### Added @@ -36,9 +37,16 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/). subscription interrupts, and host-initiated Renderer subscription codecs. - Generate complete App, Widget, Worker, and Worker-only method catalogs from the canonical protocol schema. +- Host-owned background notification receiving on Notifications actions 2 through 7, with durable consent, revision-fenced watches and receipts, authenticated delivery and activation, and resident native/WASM transport hooks. Receipt results distinguish confirmed OS display from an outstanding display claim; explicit display failures release the claim. Hosts without an adapter explicitly report unsupported. +- Generic Ed25519 notification assertions in bounded standard Gordian envelopes, with opaque byte-leaf digest witnesses and exact chain/channel plus authenticated topic filtering, exposed through `@parity/truapi/notification-envelope`. +- A wallet-free, single-writer browser receiving runtime and immutable execution-scope dispatch. Receiving consent lasts only through its approved watch expiry (at most 30 days); transport leases may renew within that bound without product UI. Native/provider adapters and real-device qualification remain host responsibilities; resident hooks do not imply delivery after a desktop host fully quits. +- Foreground receipts distinguish confirmed OS display from an unresolved durable display reservation. Hosts confirm successful presentation or cancel a known failure; an unknown outcome after restart remains pending until event expiry and must not trigger a competing OS alert. Provider-rendered APNs alerts remain advisory until authenticated local processing. ### Fixed +- Message catch-up receipts preserve an already queued notification activation until the product explicitly acknowledges its sequence. +- Generated WASM bridges preserve owned `String` parameters instead of emitting unsized `str` arguments. + - persist typed Statement Store allowance approvals and denials per product and account selector for implicit, idempotent provisioning; explicit requests for additional quota retain per-operation confirmation and increase semantics. diff --git a/Cargo.lock b/Cargo.lock index 146bcdc766..555aa493a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1737,6 +1737,20 @@ dependencies = [ "signature", ] +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "ed25519-zebra" version = "4.2.0" @@ -6295,6 +6309,7 @@ dependencies = [ "chacha20poly1305", "console_error_panic_hook", "derive_more 2.1.1", + "ed25519-dalek", "frame-metadata", "futures", "futures-timer", diff --git a/Cargo.toml b/Cargo.toml index 22a9030373..8df2e445a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ console_error_panic_hook = "0.1" convert_case = "0.6" crossterm = "0.29" derive_more = { version = "2", default-features = false } +ed25519-dalek = { version = "2", default-features = false, features = ["alloc"] } flate2 = "1" frame-metadata = { version = "23", default-features = false } fs2 = "0.4" diff --git a/README.md b/README.md index 5cf44ec471..e50a501fc5 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,16 @@ navigation and requires `Notifications` for push delivery. Hosts preserve the us `Deny` choice; Rust owns one-use grants for Rust-backed executions. Android permission prompts belong to one request and close when it finishes or is cancelled, including cancellation while the app is backgrounded. +Background receiving appends actions 2–7 to Notifications without changing send/cancel. +The resident Rust service owns consent, revision-fenced watches, authenticated delivery, +receipts and activation; each product execution supplies an immutable verified authority. +The browser's single-writer `WasmNotificationReceiver` uses the same service without +starting a wallet or product runtime. Enrollment needs a host receiving adapter and +separate consent; unsupported hosts report that explicitly. Logout revokes locally +without waiting for a relay. See the [host receiving contract](js/packages/truapi-host/README.md) +and [product notification helpers](js/packages/truapi/README.md). These hooks do not +establish OS/provider delivery guarantees or replace the separate PolkaVM runtime. + The shared Rust core asks blessed products (`peopl`, `dim2` and `stash`, on every supported network) only for device permissions and legacy-account signing. All other operations it handles bypass permission prompts and recorded decisions. diff --git a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt index f9c11e9455..af62afe101 100644 --- a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt +++ b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt @@ -107,6 +107,10 @@ import uniffi.truapi.PlacedContactLabels import uniffi.truapi.HostContactsPlaceLabelsException import uniffi.truapi.NativeContactsCallbacks import uniffi.truapi.SsoRequestOutcome +import uniffi.truapi.ReceivingAuthority +import uniffi.truapi.ReceivingWatch +import uniffi.truapi.ReceivingRegistration +import uniffi.truapi.ReceivingEvent /** Package metadata. */ object TrUAPIHost { @@ -234,6 +238,21 @@ interface HostBridge : NativeChatFilesHost { @Throws(HostRejection::class) fun cancelNotification(id: UInt) {} + /** Resident bridge returns current host scope even with products closed. + * Product bridge returns immutable verified artifact/account scope captured at execution creation. */ + suspend fun receiverAuthority(productId: String): ReceivingAuthority? = null + + /** Receiving consent is distinct from OS notification permission. */ + suspend fun receiverConsent(authority: ReceivingAuthority, watches: List): Boolean = + throw HostRejection.Rejected("background receiving unsupported") + + /** Wake synchronization without waiting for a provider or network. */ + suspend fun receiverChanged(): Unit = + throw HostRejection.Rejected("background receiving unsupported") + + /** Forward to the sole receiving owner, or return null to use the native engine. */ + suspend fun receiverCommand(productId: String, action: UByte, payload: ByteArray): ByteArray? = null + /** * Prompt for a device-level permission [product] requested on the main * thread, suspending until the user decides. Preserve whether approval @@ -558,6 +577,18 @@ private class HostCallbackAdapter(private val bridge: HostBridge) : HostCallback override fun cancelNotification(id: UInt) = withHostRejection { bridge.cancelNotification(id) } + override suspend fun receiverAuthority(productId: String): ReceivingAuthority? = + withHostRejection { bridge.receiverAuthority(productId) } + + override suspend fun receiverConsent(authority: ReceivingAuthority, watches: List): Boolean = + withHostRejection { bridge.receiverConsent(authority, watches) } + + override suspend fun receiverChanged() = + withHostRejection { bridge.receiverChanged() } + + override suspend fun receiverCommand(productId: String, action: UByte, payload: ByteArray): ByteArray? = + withHostRejection { bridge.receiverCommand(productId, action, payload) } + override suspend fun devicePermission( product: ProductExecutionConfig, request: HostDevicePermissionRequest, @@ -930,6 +961,44 @@ class TrUAPIHostRuntime @Throws(NativeRuntimeConfigException::class) constructor return TrUAPIProductExecution(execution, adapter, chatAdapter, pocketAdapter) } + /** All durable registrations; inspect syncPending before synchronizing. */ + suspend fun receivingPending(): List = inner.receivingPending() + + suspend fun receivingSynchronized(productId: String, revision: ULong): Boolean = + inner.receivingSynchronized(productId, revision) + + suspend fun receivingIngest( + productId: String, revision: ULong, watchId: String, + actualGenesis: String, actualChannel: String, actualTopics: List, frame: ByteArray, + ): List = inner.receivingIngest(productId, revision, watchId, actualGenesis, actualChannel, actualTopics, frame) + + suspend fun receivingIngestStatement( + productId: String, revision: ULong, watchId: String, + actualGenesis: String, statement: ByteArray, + ): List = inner.receivingIngestStatement(productId, revision, watchId, actualGenesis, statement) + + /** Reserve display only after rechecking current authority and receipts. */ + suspend fun receivingPrepareDisplay(productId: String, revision: ULong, eventId: String): ReceivingEvent? = + inner.receivingPrepareDisplay(productId, revision, eventId) + + /** Read-only authorization before opening a verified product, with sequence zero. */ + suspend fun receivingValidateActivation(productId: String, revision: ULong, eventId: String): ReceivingEvent? = + inner.receivingValidateActivation(productId, revision, eventId) + + suspend fun receivingConfirmDisplay(productId: String, revision: ULong, eventId: String) = + inner.receivingConfirmDisplay(productId, revision, eventId) + + /** Clear only an explicitly failed display; an unknown outcome remains pending. */ + suspend fun receivingCancelDisplay(productId: String, revision: ULong, eventId: String) = + inner.receivingCancelDisplay(productId, revision, eventId) + + suspend fun receivingActivate(productId: String, revision: ULong, eventId: String): ReceivingEvent? = + inner.receivingActivate(productId, revision, eventId) + + suspend fun receivingRevoke(productId: String) = inner.receivingRevoke(productId) + + suspend fun receivingMarkTransportChanged(productId: String) = inner.receivingMarkTransportChanged(productId) + /** * Take one reference on the product's worker for a modality holder that is * on screen or in flight. The first one reports a start transition to diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index b753adf0c7..e62677de57 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -119,6 +119,16 @@ public protocol HostBridge: NativeChatFilesHost { /// Cancel a previously scheduled notification id. func cancelNotification(id: UInt32) throws + /// Resident bridge: current host scope even with products closed. + /// Product bridge: immutable verified artifact/account scope captured at execution creation. + func receiverAuthority(productId: String) async throws -> ReceivingAuthority? + /// Request receiving consent separately from OS notification permission. + func receiverConsent(authority: ReceivingAuthority, watches: [ReceivingWatch]) async throws -> Bool + /// Wake synchronization without waiting for a provider or network. + func receiverChanged() async throws + /// Forward to the sole receiving owner, or return nil to use the native engine. + func receiverCommand(productId: String, action: UInt8, payload: Data) async throws -> Data? + /// Prompt for a device-level permission `product` requested on the main /// actor, suspending until the user decides. Preserve the approval lifetime. func devicePermission( @@ -361,6 +371,14 @@ public extension HostBridge { func onCoreLog(marker: String, detail: String) {} func pushNotification(request: HostPushNotificationRequest) async throws -> UInt32 { 0 } func cancelNotification(id: UInt32) throws {} + func receiverAuthority(productId: String) async throws -> ReceivingAuthority? { nil } + func receiverConsent(authority: ReceivingAuthority, watches: [ReceivingWatch]) async throws -> Bool { + throw HostRejection.rejected(reason: "background receiving unsupported") + } + func receiverChanged() async throws { + throw HostRejection.rejected(reason: "background receiving unsupported") + } + func receiverCommand(productId: String, action: UInt8, payload: Data) async throws -> Data? { nil } func authStateChanged(state: AuthState) {} func chainConnect(genesisHash: Data) throws -> UInt32? { nil } func allowedHopEndpoints(bulletinGenesisHash: Data) async throws -> [String] { [] } @@ -649,6 +667,24 @@ private final class HostCallbackAdapter: HostCallbacks, @unchecked Sendable { } } + func receiverAuthority(productId: String) async throws -> ReceivingAuthority? { + try await withHostRejection { try await bridge.receiverAuthority(productId: productId) } + } + + func receiverConsent(authority: ReceivingAuthority, watches: [ReceivingWatch]) async throws -> Bool { + try await withHostRejection { try await bridge.receiverConsent(authority: authority, watches: watches) } + } + + func receiverChanged() async throws { + try await withHostRejection { try await bridge.receiverChanged() } + } + + func receiverCommand(productId: String, action: UInt8, payload: Data) async throws -> Data? { + try await withHostRejection { + try await bridge.receiverCommand(productId: productId, action: action, payload: payload) + } + } + func devicePermission( product: ProductExecutionConfig, request: HostDevicePermissionRequest @@ -1023,6 +1059,62 @@ public final class TrUAPIHostRuntime: @unchecked Sendable { inner.disconnect() } + /// All durable registrations; inspect `syncPending` before synchronizing. + public func receivingPending() async throws -> [ReceivingRegistration] { + try await inner.receivingPending() + } + + public func receivingSynchronized(productId: String, revision: UInt64) async throws -> Bool { + try await inner.receivingSynchronized(productId: productId, revision: revision) + } + + public func receivingIngest( + productId: String, revision: UInt64, watchId: String, + actualGenesis: String, actualChannel: String, actualTopics: [String], frame: Data + ) async throws -> [ReceivingEvent] { + try await inner.receivingIngest(productId: productId, revision: revision, watchId: watchId, + actualGenesis: actualGenesis, actualChannel: actualChannel, actualTopics: actualTopics, frame: frame) + } + + public func receivingIngestStatement( + productId: String, revision: UInt64, watchId: String, + actualGenesis: String, statement: Data + ) async throws -> [ReceivingEvent] { + try await inner.receivingIngestStatement(productId: productId, revision: revision, watchId: watchId, + actualGenesis: actualGenesis, statement: statement) + } + + /// Reserve a display only after the core rechecks current authority and receipts. + public func receivingPrepareDisplay(productId: String, revision: UInt64, eventId: String) async throws -> ReceivingEvent? { + try await inner.receivingPrepareDisplay(productId: productId, revision: revision, eventId: eventId) + } + + /// Read-only authorization before opening a verified product, with sequence zero. + public func receivingValidateActivation(productId: String, revision: UInt64, eventId: String) async throws -> ReceivingEvent? { + try await inner.receivingValidateActivation(productId: productId, revision: revision, eventId: eventId) + } + + public func receivingConfirmDisplay(productId: String, revision: UInt64, eventId: String) async throws { + try await inner.receivingConfirmDisplay(productId: productId, revision: revision, eventId: eventId) + } + + /// Clear only an explicitly failed display; an unknown outcome remains pending. + public func receivingCancelDisplay(productId: String, revision: UInt64, eventId: String) async throws { + try await inner.receivingCancelDisplay(productId: productId, revision: revision, eventId: eventId) + } + + public func receivingActivate(productId: String, revision: UInt64, eventId: String) async throws -> ReceivingEvent? { + try await inner.receivingActivate(productId: productId, revision: revision, eventId: eventId) + } + + public func receivingRevoke(productId: String) async throws { + try await inner.receivingRevoke(productId: productId) + } + + public func receivingMarkTransportChanged(productId: String) async throws { + try await inner.receivingMarkTransportChanged(productId: productId) + } + /// Take one reference on the product's worker for a modality holder that /// is on screen or in flight. The first one reports `.start` to /// ``HostBridge/workerDemandChanged(productId:transition:)``, which is diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 8e7283cabc..bc7a7c570f 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -20,6 +20,8 @@ The package exposes tree-shakeable subpath exports — import only what your env | `@parity/truapi-host/testing/dev-accounts` | Named dev accounts derived from fixed BIP-39 entropy, which sign for real. | | `@parity/truapi-host/testing/host-page` | The browser half the fixture drives, for a suite that boots its own page. | | `@parity/truapi-host/wasm/testing` | The raw glue for the signing-enabled bundle the test host runs on. | +| `@parity/truapi-host/browser-receiving` | Trusted host-page client for the durable service-worker receiving owner. | +| `@parity/truapi-host/browser-receiving-worker` | Service-worker installation helper with an injected canonical WASM factory. | `scripts/build-wasm.mjs` builds two WASM bundles, both `--no-default-features`. `wasm/web` is the production browser host and excludes `WasmSigningHostRuntime`; `wasm/testing` adds the Rust `wasm-signing-host` and `test-host` features, @@ -122,6 +124,86 @@ Activation changes, disconnect, disposal and worker failure invalidate pending r seconds rather than leaving the caller pending; native read work may finish later, but cannot populate a replaced wallet. Shells must additionally fence their selected network/product context. +## Durable browser receiving + +Compose `installBrowserReceivingWorker` into the existing root-scoped host service +worker. It owns opaque canonical receiving ledger bytes in IndexedDB, serializes +core access with Web Locks across worker replacement, and uses the generic relay +v2 WebPush endpoints. Products never receive its authority registry, transport +credentials, or raw host hooks. + +```ts +import init, { WasmNotificationReceiver } from "@parity/truapi-host/wasm/web"; +import { installBrowserReceivingWorker } from "@parity/truapi-host/browser-receiving-worker"; + +const ready = init({ + module_or_path: new URL("/receiving/truapi_server_bg.wasm", self.location.origin), +}); +installBrowserReceivingWorker({ + scope: self, + createReceiver: async callbacks => { + await ready; + return new WasmNotificationReceiver(callbacks); + }, + relayUrl: RECEIVING_RELAY_URL, + pushOrigin: self.location.origin, + hostEntryUrl: "/", + notificationTitle: "New activity", +}); +``` + +Build this entry with the host's existing service-worker build. A classic worker +can be bundled with `esbuild host-sw.ts --bundle --format=iife --platform=browser +--outfile=public/sw.js`; preserve existing cache/install/push handlers. Do not use +dynamic `import()` in a service worker. Copy the matching +`dist/wasm/web/truapi_server_bg.wasm` to the explicit URL above, permit that +same-origin asset and WASM compilation in CSP, and keep its cache revision tied +to the bundled glue. A standalone receiving artifact does not replace a host's +different PolkaVM runtime artifact. + +The page imports `createBrowserReceivingClient` from `/browser-receiving` with its +existing `ServiceWorkerRegistration`, a real host `consent(authority, watches)` +prompt, and an `activate(authority, event)` callback. Activation returns true +only after the exact verified product is ready in the correct unlocked account +and environment. It must never silently switch accounts or navigate `event.route` +as a URL. That route is an opaque product token. + +Read `getAuthority(productId)` to recover the durable generation. It returns the +canonical authority plus `revoked`; reuse a live matching scope's generation +across reloads, and increment it for account/artifact replacement or explicit +re-enrollment after revocation. Call `updateAuthority` only with host-verified +scope, then `bindExecution` with an immutable snapshot. Forward page-core +`receiverCommand` through that execution's `command(action, payload)`, checking +the callback product ID against the trusted execution closure. The worker calls +canonical `commandForExecution`, including its post-consent scope recheck. +Call execution `ready()` once the verified product is ready and `close()` on +suspension. Suspension/lock retains enrollment; logout must await local `revoke` +before forgetting identity. `revoke` never waits for relay deletion. + +Call `enableWebPush(vapidPublicKey)` directly from a user gesture. Obtain the +trusted relay's VAPID key from `GET /v2/config`, not a product-provided URL. +`refresh()` refreshes the browser destination; online, push, worker activation +and supported background/periodic sync events retry durable transport work. +Network requests have a ten-second abort deadline and bounded responses; retry +backoff and pending deletion survive worker termination. Standard WebPush +subscriptions use `userVisibleOnly: true`. Invalid, stale, revoked or +foreground-receipted wakes never cause a fabricated notification. + +The core alone gates authenticated ingress, foreground receipts, reservations +and click activation. The worker confirms display only after `showNotification` +resolves and cancels reservations only on explicit display failure. Retained +v2 responses contain a carrier and actual source metadata, so this adapter uses +`receivingIngest`, not `receivingIngestStatement`. + +Relay watches retain each original core-consent expiry, at most 30 days, without +a separate 24-hour lease or dependence on periodic browser execution. Transport +rotation and retries never extend that expiry; renewal beyond it requires fresh +core consent. Event/header freshness remains independently bounded to 24 hours. +Browser background scheduling is not guaranteed. Unsupported Web Locks, WebPush, Ed25519 +WebCrypto, denied notification permission, or unavailable WASM are not simulated +as successful support. Physical page-closed delivery still requires a secure +origin, valid relay/VAPID configuration and browser/provider qualification. + ## Bundler requirements The worker imports the WASM glue by a literal specifier, so every bundler resolves it statically and emits diff --git a/js/packages/truapi-host/package.json b/js/packages/truapi-host/package.json index 2dfd9f2aef..27225d6401 100644 --- a/js/packages/truapi-host/package.json +++ b/js/packages/truapi-host/package.json @@ -29,6 +29,14 @@ "types": "./dist/web/index.d.ts", "import": "./dist/web/index.js" }, + "./browser-receiving": { + "types": "./dist/browser-receiving.d.ts", + "import": "./dist/browser-receiving.js" + }, + "./browser-receiving-worker": { + "types": "./dist/browser-receiving-worker.d.ts", + "import": "./dist/browser-receiving-worker.js" + }, "./testing": { "types": "./dist/testing.d.ts", "import": "./dist/testing.js" diff --git a/js/packages/truapi-host/src/browser-receiving-transport.test.ts b/js/packages/truapi-host/src/browser-receiving-transport.test.ts new file mode 100644 index 0000000000..0401550864 --- /dev/null +++ b/js/packages/truapi-host/src/browser-receiving-transport.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { BrowserReceivingTransport, createReceiverEnrollment } from "./browser-receiving-transport.js"; +import type { ReceivingAuthority } from "./runtime.js"; + +const originalFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = originalFetch; }); + +const authority: ReceivingAuthority = { + productId: "receiver.test", account: "11".repeat(32), environment: "test", + artifact: "22".repeat(32), genesis: "33".repeat(32), generation: 4n, + osPermission: true, transportReady: true, +}; + +const decodeHex = (value: string) => Uint8Array.from(value.match(/../g)!, byte => Number.parseInt(byte, 16)); + +describe("browser receiving relay v2 transport", () => { + it("preserves the original core-consent expiry beyond 24 hours without extending it", async () => { + const enrollment = await createReceiverEnrollment(authority); + enrollment.relayRevision = 7; + enrollment.coreRevision = 9007199254740993n; + const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000; + enrollment.routes = { watch: "44".repeat(32) }; + let request: Request | undefined; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + request = new Request(input, init); + return new Response(JSON.stringify({ ok: true })); + }) as typeof fetch; + const transport = new BrowserReceivingTransport("https://relay.test", "https://host.test"); + await transport.register(enrollment, { + authority, revision: enrollment.coreRevision, enabled: true, syncPending: true, + watches: [{ id: "watch", genesis: authority.genesis, channel: "55".repeat(32), topics: ["66".repeat(32)], + senders: ["88".repeat(32), "77".repeat(32)], expiresAt: BigInt(expiresAt), + mutedUntil: 18446744073709551615n, route: "private-product-route" }], + }, { endpoint: "https://push.test/subscription", keys: { auth: "auth", p256dh: "p256dh" } }); + expect(request!.url).toBe(`https://relay.test/v2/receivers/${enrollment.deviceId}`); + expect(request!.headers.get("authorization")).toBe(`Bearer ${enrollment.secret}`); + const wire = await request!.json(); + expect(wire.revision).toBe(7); + expect(wire.watches[0].expiresAt).toBe(expiresAt); + expect(wire.watches[0].mutedUntil).toBe(Number.MAX_SAFE_INTEGER); + expect(wire.watches[0].senderKeys).toEqual(["77".repeat(32), "88".repeat(32)]); + expect(wire.watches[0].routeToken).toBe(enrollment.routes.watch); + const serialized = JSON.stringify(wire); + for (const secret of [authority.account, authority.artifact, "private-product-route", enrollment.coreRevision.toString()]) { + expect(serialized.includes(secret)).toBe(false); + } + const publicKey = await crypto.subtle.importKey("raw", decodeHex(wire.binding.ownerKey), "Ed25519", false, ["verify"]); + expect(await crypto.subtle.verify("Ed25519", publicKey, decodeHex(wire.binding.signature), new TextEncoder().encode(JSON.stringify([ + "truapi:receiver-binding:v2", 2, authority.productId, "https://host.test", enrollment.deviceId, + enrollment.ownerKey, wire.binding.issuedAt, + ])))).toBe(true); + }); + + it("decodes retained frames only for the exact transport revision", async () => { + const enrollment = await createReceiverEnrollment(authority); + enrollment.relayRevision = 8; + let revision = 8; + globalThis.fetch = (async () => Response.json({ v: 2, revision, watchId: "watch", + genesis: authority.genesis, channel: "55".repeat(32), topics: ["66".repeat(32)], frame: "AQID" })) as typeof fetch; + const transport = new BrowserReceivingTransport("https://relay.test", "https://host.test"); + expect((await transport.event(enrollment, "event")).frame).toEqual(new Uint8Array([1, 2, 3])); + revision = 9; + await expect(transport.event(enrollment, "event")).rejects.toThrow("invalid retained receiving event"); + }); + + it("revokes with the transport revision and bearer, never core revision", async () => { + const enrollment = await createReceiverEnrollment(authority); + enrollment.relayRevision = 10; + enrollment.coreRevision = 1000n; + let request: Request | undefined; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + request = new Request(input, init); + return new Response(null, { status: 204 }); + }) as typeof fetch; + await new BrowserReceivingTransport("https://relay.test", "https://host.test").revoke(enrollment); + expect(request!.method).toBe("DELETE"); + expect(await request!.json()).toEqual({ revision: 10 }); + }); + + it("rejects nonlocal plaintext relays and oversized retained responses", async () => { + expect(() => new BrowserReceivingTransport("http://relay.test", "https://host.test")).toThrow("requires HTTPS"); + const enrollment = await createReceiverEnrollment(authority); + globalThis.fetch = (async () => new Response("x".repeat(384 * 1024 + 1))) as typeof fetch; + await expect(new BrowserReceivingTransport("https://relay.test", "https://host.test").event(enrollment, "event")) + .rejects.toThrow("oversized receiving relay response"); + }); +}); diff --git a/js/packages/truapi-host/src/browser-receiving-transport.ts b/js/packages/truapi-host/src/browser-receiving-transport.ts new file mode 100644 index 0000000000..64fd81b30f --- /dev/null +++ b/js/packages/truapi-host/src/browser-receiving-transport.ts @@ -0,0 +1,115 @@ +import type { ReceivingAuthority, ReceivingRegistration } from "./runtime.js"; + +const hex = (bytes: ArrayBuffer | Uint8Array): string => + Array.from(new Uint8Array(bytes instanceof Uint8Array ? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) : bytes), byte => byte.toString(16).padStart(2, "0")).join(""); +export const randomReceiverToken = (): string => hex(crypto.getRandomValues(new Uint8Array(32))); + +/** Host-private transport credentials. Never expose these records to products. */ +export interface BrowserReceiverEnrollment { + authority: ReceivingAuthority; + deviceId: string; + secret: string; + ownerKey: string; + privateKey: CryptoKey; + relayRevision: number; + coreRevision: bigint; + routes: Record; + acknowledged: boolean; + revoked: boolean; +} + +export async function createReceiverEnrollment(authority: ReceivingAuthority): Promise { + const keys = await crypto.subtle.generateKey({ name: "Ed25519" }, false, ["sign", "verify"]) as CryptoKeyPair; + const secret = randomReceiverToken(); + const secretBytes = Uint8Array.from(secret.match(/../g)!, value => Number.parseInt(value, 16)); + return { + authority, secret, privateKey: keys.privateKey, + deviceId: hex(await crypto.subtle.digest("SHA-256", secretBytes)), + ownerKey: hex(await crypto.subtle.exportKey("raw", keys.publicKey)), + relayRevision: 0, coreRevision: 0n, routes: {}, + acknowledged: false, revoked: false, + }; +} + +/** The configured relay is trusted transport, never authority for app content. */ +export class BrowserReceivingTransport { + private readonly base: URL; + constructor(relayUrl: string, private readonly origin: string) { + this.base = new URL(relayUrl); + if (this.base.protocol !== "https:" && !(this.base.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(this.base.hostname))) { + throw new Error("receiving relay requires HTTPS"); + } + if (this.base.username || this.base.password || this.base.search || this.base.hash) throw new Error("invalid receiving relay URL"); + } + + private async request(path: string, enrollment: BrowserReceiverEnrollment, method: string, body?: unknown): Promise { + const controller = new AbortController(); + const deadline = setTimeout(() => controller.abort(), 10_000); + try { + const response = await fetch(new URL(`${this.base.pathname.replace(/\/$/, "")}${path}`, this.base.origin), { + method, credentials: "omit", redirect: "error", cache: "no-store", signal: controller.signal, + headers: { Authorization: `Bearer ${enrollment.secret}`, "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!response.ok) throw new Error(`receiving relay HTTP ${response.status}`); + if (method !== "GET") { await response.body?.cancel(); return undefined; } + const reader = response.body?.getReader(); + if (!reader) throw new Error("empty receiving relay response"); + const parts: Uint8Array[] = []; + let size = 0; + while (true) { + const part = await reader.read(); + if (part.done) break; + size += part.value.byteLength; + if (size > 384 * 1024) { await reader.cancel(); throw new Error("oversized receiving relay response"); } + parts.push(part.value); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const part of parts) { bytes.set(part, offset); offset += part.length; } + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + } finally { clearTimeout(deadline); } + } + + async register(enrollment: BrowserReceiverEnrollment, registration: ReceivingRegistration, destination: PushSubscriptionJSON): Promise { + const issuedAt = Date.now(); + const { productId: product } = enrollment.authority; + const { deviceId, ownerKey } = enrollment; + const tuple = ["truapi:receiver-binding:v2", 2, product, this.origin, deviceId, ownerKey, issuedAt]; + const signature = hex(await crypto.subtle.sign("Ed25519", enrollment.privateKey, new TextEncoder().encode(JSON.stringify(tuple)))); + await this.request(`/v2/receivers/${deviceId}`, enrollment, "PUT", { + binding: { v: 2, product, origin: this.origin, deviceId, ownerKey, issuedAt, signature }, + destination: { endpoint: destination.endpoint, keys: destination.keys }, + revision: enrollment.relayRevision, enabled: true, + watches: registration.watches.filter(watch => watch.expiresAt > BigInt(issuedAt)).map(watch => ({ + watchId: watch.id, genesis: watch.genesis, channel: watch.channel, topics: watch.topics, + senderKeys: [...watch.senders].sort(), + expiresAt: Number(watch.expiresAt), + mutedUntil: Number(watch.mutedUntil > BigInt(Number.MAX_SAFE_INTEGER) ? BigInt(Number.MAX_SAFE_INTEGER) : watch.mutedUntil), + routeToken: enrollment.routes[watch.id], + })), + }); + } + + async revoke(enrollment: BrowserReceiverEnrollment): Promise { + await this.request(`/v2/receivers/${enrollment.deviceId}`, enrollment, "DELETE", { revision: enrollment.relayRevision }); + } + + async event(enrollment: BrowserReceiverEnrollment, eventId: string): Promise<{ + revision: number; watchId: string; genesis: string; channel: string; topics: string[]; frame: Uint8Array; + }> { + if (!/^[A-Za-z0-9._:-]{1,256}$/.test(eventId)) throw new Error("invalid receiver event ID"); + const value = await this.request(`/v2/receivers/${enrollment.deviceId}/events/${encodeURIComponent(eventId)}`, enrollment, "GET") as Record; + if (!value || value.v !== 2 || value.revision !== enrollment.relayRevision || typeof value.watchId !== "string" || + typeof value.genesis !== "string" || typeof value.channel !== "string" || !Array.isArray(value.topics) || + value.topics.length > 4 || !value.topics.every(topic => typeof topic === "string" && /^[a-f0-9]{64}$/.test(topic)) || + !/^[a-f0-9]{64}$/.test(value.genesis) || !/^[a-f0-9]{64}$/.test(value.channel) || + typeof value.frame !== "string" || value.frame.length > 349528 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value.frame)) { + throw new Error("invalid retained receiving event"); + } + const frame = Uint8Array.from(atob(value.frame), value => value.charCodeAt(0)); + if (frame.length > 256 * 1024) throw new Error("oversized receiving frame"); + return { revision: value.revision as number, watchId: value.watchId, genesis: value.genesis, + channel: value.channel, topics: value.topics as string[], frame }; + } +} diff --git a/js/packages/truapi-host/src/browser-receiving-worker.ts b/js/packages/truapi-host/src/browser-receiving-worker.ts new file mode 100644 index 0000000000..acbd0947e1 --- /dev/null +++ b/js/packages/truapi-host/src/browser-receiving-worker.ts @@ -0,0 +1,513 @@ +import type { ReceivingEvent, ReceivingWatch } from "@parity/truapi"; +import { + ReceivingAuthority, createNotificationReceiverCallbacks, + receivingRegistrationsCodec, receivingEventsCodec, receivingEventCodec, + type RawNotificationReceiver, type ReceivingRegistration, +} from "./runtime.js"; +import { + BrowserReceivingTransport, createReceiverEnrollment, randomReceiverToken, + type BrowserReceiverEnrollment, +} from "./browser-receiving-transport.js"; + +export interface BrowserReceivingWorkerOptions { + scope: ServiceWorkerGlobalScope; + createReceiver(callbacks: { + receiverAuthority(productId: string): Promise; + receiverConsent(authority: Uint8Array, watches: Uint8Array): Promise; + receiverChanged(): Promise; + readReceivingState(): Promise; + writeReceivingState(bytes: Uint8Array): Promise; + }): Promise | RawNotificationReceiver; + relayUrl: string; + /** Must equal the relay's configured PUSH_ORIGIN, not a product URL. */ + pushOrigin: string; + /** Fixed trusted same-origin host entry. Routes in events are never navigated. */ + hostEntryUrl: string; + notificationTitle: string; + databaseName?: string; +} + +interface AuthorityEntry { authority: ReceivingAuthority; revoked: boolean } +interface Execution { authority: ReceivingAuthority; clientId: string; ready: boolean } +interface Activation { authority: ReceivingAuthority; revision: bigint; eventId: string; expiresAt: bigint } +interface Wake { v: 2; deviceId: string; routeToken: string; messageId: string; revision: number } +interface SyncRegistration { enrollment: BrowserReceiverEnrollment; registration?: ReceivingRegistration } + +function sameScope(left: ReceivingAuthority, right: ReceivingAuthority): boolean { + return left.productId === right.productId && left.account === right.account && + left.environment === right.environment && left.artifact === right.artifact && + left.genesis === right.genesis && left.generation === right.generation; +} + +class ReceiverDatabase { + private readonly opened: Promise; + constructor(name: string) { + this.opened = new Promise((resolve, reject) => { + const request = indexedDB.open(name, 1); + request.onupgradeneeded = () => request.result.createObjectStore("receiver"); + request.onerror = () => reject(request.error); + request.onblocked = () => reject(new Error("receiving database upgrade blocked")); + request.onsuccess = () => { + request.result.onversionchange = () => request.result.close(); + resolve(request.result); + }; + }); + } + async get(key: string): Promise { + const database = await this.opened; + return new Promise((resolve, reject) => { + const transaction = database.transaction("receiver", "readonly"); + const request = transaction.objectStore("receiver").get(key); + transaction.oncomplete = () => resolve(request.result as T | undefined); + transaction.onabort = () => reject(transaction.error); + transaction.onerror = () => reject(transaction.error); + }); + } + async update(key: string, change: (previous: T | undefined) => T | undefined): Promise { + const database = await this.opened; + return new Promise((resolve, reject) => { + const transaction = database.transaction("receiver", "readwrite"); + const store = transaction.objectStore("receiver"); + const request = store.get(key); + let failure: unknown; + request.onsuccess = () => { + try { + const next = change(request.result as T | undefined); + if (next === undefined) store.delete(key); else store.put(next, key); + } catch (error) { failure = error; transaction.abort(); } + }; + transaction.oncomplete = () => resolve(); + transaction.onabort = () => reject(failure ?? transaction.error); + transaction.onerror = () => reject(transaction.error); + }); + } + put(key: string, value: T): Promise { return this.update(key, () => value); } + async entries(prefix: string): Promise> { + const database = await this.opened; + return new Promise((resolve, reject) => { + const transaction = database.transaction("receiver", "readonly"); + const request = transaction.objectStore("receiver").openCursor(IDBKeyRange.bound(prefix, `${prefix}\uffff`)); + const values: Array<[string, T]> = []; + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) return; + values.push([String(cursor.key), cursor.value as T]); + cursor.continue(); + }; + transaction.oncomplete = () => resolve(values); + transaction.onabort = () => reject(transaction.error); + transaction.onerror = () => reject(transaction.error); + }); + } +} + +/** Compose into the host's existing service worker. Installs no competing worker. */ +export function installBrowserReceivingWorker(options: BrowserReceivingWorkerOptions): void { + const { scope } = options; + const entryUrl = new URL(options.hostEntryUrl, scope.location.origin); + if (entryUrl.origin !== scope.location.origin || entryUrl.username || entryUrl.password) throw new Error("receiving host entry must be same-origin"); + if (options.pushOrigin !== scope.location.origin) throw new Error("receiving origin must match the trusted host"); + if (!scope.navigator.locks) throw new Error("durable receiving requires Web Locks"); + const databaseName = options.databaseName ?? "truapi-browser-receiving"; + const database = new ReceiverDatabase(databaseName); + const transport = new BrowserReceivingTransport(options.relayUrl, options.pushOrigin); + const executions = new Map(); + const activating = new Set(); + let synchronization: Promise | undefined; + + async function askHost(clientId: string, operation: "consent" | "activate", authority: ReceivingAuthority, value: ReceivingWatch[] | ReceivingEvent): Promise { + const client = await scope.clients.get(clientId); + if (!client || client.type !== "window" || new URL(client.url).origin !== scope.location.origin || (client as WindowClient).frameType !== "top-level") return false; + const channel = new MessageChannel(); + return new Promise(resolve => { + const timer = setTimeout(() => finish(false), 25_000); + function finish(value: boolean) { clearTimeout(timer); channel.port1.close(); resolve(value); } + channel.port1.onmessage = event => finish(event.data === true); + channel.port1.onmessageerror = () => finish(false); + client.postMessage({ type: "truapi:receiving-host", operation, authority, + ...(operation === "consent" ? { watches: value } : { event: value }) }, [channel.port2]); + }); + } + + async function liveAuthority(product: string): Promise { + const entry = await database.get(`authority:${product}`); + if (!entry || entry.revoked) return undefined; + const destination = await database.get("destination"); + return { ...entry.authority, osPermission: Notification.permission === "granted", + transportReady: Boolean(destination?.endpoint) }; + } + + // Reconstruct for every exclusive lease: a replaced worker must never use a stale in-memory ledger. + async function withCore(operation: (receiver: RawNotificationReceiver) => Promise, consentClient?: string, executionLive?: () => boolean): Promise { + const abort = new AbortController(); + const timer = setTimeout(() => abort.abort(), 40_000); + try { + return await scope.navigator.locks.request(`${databaseName}:writer`, { signal: abort.signal }, async () => { + clearTimeout(timer); + const receiver = await options.createReceiver(createNotificationReceiverCallbacks({ + receiverAuthority: async product => { + if (executionLive && !executionLive()) return undefined; + const authority = await liveAuthority(product); + return executionLive && !executionLive() ? undefined : authority; + }, + receiverConsent: (authority, watches) => consentClient && (!executionLive || executionLive()) + ? askHost(consentClient, "consent", authority, watches) : false, + receiverChanged: () => database.put("syncRequested", true), + readReceivingState: () => database.get("ledger"), + writeReceivingState: bytes => database.put("ledger", bytes), + })); + try { return await operation(receiver); } finally { receiver.free(); } + }); + } finally { clearTimeout(timer); } + } + + async function queueRetry(): Promise { + await database.put("syncRequested", true); + const registration = scope.registration as ServiceWorkerRegistration & { sync?: { register(tag: string): Promise } }; + await registration.sync?.register("truapi:receiving").catch(() => {}); + const periodic = scope.registration as ServiceWorkerRegistration & { periodicSync?: { register(tag: string, options: { minInterval: number }): Promise } }; + await periodic.periodicSync?.register("truapi:receiving", { minInterval: 12 * 60 * 60 * 1000 }).catch(() => {}); + } + + async function revokeEnrollments(product: string): Promise { + for (const [key, enrollment] of await database.entries("enrollment:")) { + if (enrollment.authority.productId !== product || enrollment.revoked) continue; + await database.put(key, { ...enrollment, revoked: true, acknowledged: false, relayRevision: enrollment.relayRevision + 1 }); + } + } + + async function refreshDestination(): Promise { + const subscription = await scope.registration.pushManager.getSubscription(); + const destination = subscription?.toJSON(); + const prior = await database.get("destination"); + if (JSON.stringify(destination) === JSON.stringify(prior)) return; + await withCore(async receiver => { + await database.put("destination", destination); + for (const registration of receivingRegistrationsCodec.dec(await receiver.receivingPending())) { + if (registration.enabled) await receiver.receivingMarkTransportChanged(registration.authority.productId); + } + }); + } + + async function prepareSync(): Promise { + return withCore(async receiver => { + const registrations = receivingRegistrationsCodec.dec(await receiver.receivingPending()); + for (const [, enrollment] of await database.entries("enrollment:")) { + const authority = await database.get(`authority:${enrollment.authority.productId}`); + if (!enrollment.revoked && (authority?.revoked || (authority && !sameScope(authority.authority, enrollment.authority)))) { + await database.put(`enrollment:${enrollment.deviceId}`, { + ...enrollment, revoked: true, acknowledged: false, relayRevision: enrollment.relayRevision + 1, + }); + } + } + const destination = await database.get("destination"); + const pending: SyncRegistration[] = []; + for (const registration of registrations) { + const product = registration.authority.productId; + if (!registration.enabled) { + await revokeEnrollments(product); + const existing = (await database.entries("enrollment:")).some(([, item]) => item.authority.productId === product); + if (!existing && registration.syncPending) await receiver.receivingSynchronized(product, registration.revision); + continue; + } + if (!destination?.endpoint) continue; + let enrollment = (await database.entries("enrollment:")) + .map(([, value]) => value).find(value => !value.revoked && sameScope(value.authority, registration.authority)); + if (!enrollment) enrollment = await createReceiverEnrollment(registration.authority); + if (!enrollment.acknowledged || registration.syncPending || enrollment.coreRevision !== registration.revision) { + if (enrollment.acknowledged || enrollment.relayRevision === 0 || enrollment.coreRevision !== registration.revision) { + enrollment.relayRevision++; + if (!Number.isSafeInteger(enrollment.relayRevision)) throw new Error("relay revision exhausted"); + enrollment.coreRevision = registration.revision; + enrollment.routes = Object.fromEntries(registration.watches.map(watch => [watch.id, enrollment!.routes[watch.id] ?? randomReceiverToken()])); + } + enrollment.acknowledged = false; + await database.put(`enrollment:${enrollment.deviceId}`, enrollment); + pending.push({ enrollment, registration }); + } + } + for (const [, enrollment] of await database.entries("enrollment:")) { + if (enrollment.revoked && !enrollment.acknowledged) pending.push({ enrollment }); + } + return pending; + }); + } + + function synchronize(): Promise { + if (synchronization) return synchronization; + synchronization = scope.navigator.locks.request(`${databaseName}:relay`, async () => { + const retry = await database.get<{ attempts: number; after: number }>("retry"); + if (retry && retry.after > Date.now()) { await queueRetry(); return; } + const deadline = Date.now() + 25_000; + await database.put("syncRequested", false); + const jobs = await prepareSync(); + let failed = false; + for (const { enrollment, registration } of jobs) { + if (Date.now() >= deadline) { failed = true; break; } + try { + if (enrollment.revoked) await transport.revoke(enrollment); + else { + const authority = await liveAuthority(enrollment.authority.productId); + const current = await database.get(`enrollment:${enrollment.deviceId}`); + if (!authority || !sameScope(authority, enrollment.authority) || !current || current.revoked || current.relayRevision !== enrollment.relayRevision) continue; + const destination = await database.get("destination"); + if (!destination || !registration) continue; + await transport.register(enrollment, registration, destination); + } + await withCore(async receiver => { + const current = await database.get(`enrollment:${enrollment.deviceId}`); + if (!current || current.relayRevision !== enrollment.relayRevision || current.revoked !== enrollment.revoked) return; + await database.put(`enrollment:${enrollment.deviceId}`, enrollment.revoked ? undefined : { ...current, acknowledged: true }); + const pending = receivingRegistrationsCodec.dec(await receiver.receivingPending()) + .find(value => value.authority.productId === enrollment.authority.productId); + const deletionPending = (await database.entries("enrollment:")) + .some(([, value]) => value.authority.productId === enrollment.authority.productId && value.revoked && !value.acknowledged); + if (pending && pending.enabled === !enrollment.revoked && + (enrollment.revoked ? !deletionPending : pending.revision === enrollment.coreRevision && sameScope(pending.authority, enrollment.authority))) { + await receiver.receivingSynchronized(pending.authority.productId, pending.revision); + } + }); + } catch { failed = true; } + } + for (const [key, pending] of await database.entries<{ wake: Wake; receivedAt: number }>("wake:")) { + if (Date.now() >= deadline) { failed = true; break; } + try { + if (pending.receivedAt + 60 * 60 * 1000 > Date.now()) await receivePush(pending.wake); + await database.put(key, undefined); + } catch { failed = true; } + } + if (failed) { + const attempts = Math.min((retry?.attempts ?? 0) + 1, 8); + await database.put("retry", { attempts, after: Date.now() + Math.min(30_000 * 2 ** (attempts - 1), 60 * 60 * 1000) }); + } else await database.put("retry", undefined); + if (failed || await database.get("syncRequested")) await queueRetry(); + const periodic = scope.registration as ServiceWorkerRegistration & { periodicSync?: { register(tag: string, options: { minInterval: number }): Promise } }; + await periodic.periodicSync?.register("truapi:receiving", { minInterval: 12 * 60 * 60 * 1000 }).catch(() => {}); + }).catch(async () => { await queueRetry(); }).finally(() => { synchronization = undefined; }); + return synchronization; + } + + async function trustedClient(event: ExtendableMessageEvent): Promise { + if (!event.source || !("id" in event.source)) throw new Error("receiving requires a host window"); + const client = await scope.clients.get(event.source.id); + if (!client || client.type !== "window" || new URL(client.url).origin !== scope.location.origin || (client as WindowClient).frameType !== "top-level") { + throw new Error("untrusted receiving message source"); + } + return client as WindowClient; + } + + function boundExecution(id: string, clientId: string): Execution { + const execution = executions.get(id); + if (!execution || execution.clientId !== clientId) throw new Error("receiving execution unavailable; bind again after worker restart"); + return execution; + } + + async function deliverActivation(key: string, activation: Activation, clientId: string): Promise { + if (activating.has(key)) return; + activating.add(key); + try { + if (activation.expiresAt <= BigInt(Date.now())) { await database.put(key, undefined); return; } + const preview = await withCore(async receiver => receivingEventCodec.dec(await receiver.receivingValidateActivation( + activation.authority.productId, activation.revision, activation.eventId))); + if (!preview) { await database.put(key, undefined); return; } + const authority = await liveAuthority(activation.authority.productId); + if (!authority || !sameScope(authority, activation.authority)) return; + if (!await askHost(clientId, "activate", activation.authority, preview)) return; + const executionLive = () => { + for (const execution of executions.values()) { + if (execution.ready && execution.clientId === clientId && sameScope(execution.authority, activation.authority)) return true; + } + return false; + }; + await withCore(async receiver => { + const current = await liveAuthority(activation.authority.productId); + if (!current || !sameScope(current, activation.authority)) return; + if (!executionLive()) return; + const event = receivingEventCodec.dec(await receiver.receivingActivate(activation.authority.productId, activation.revision, activation.eventId)); + if (event) await database.put(key, undefined); + }, undefined, executionLive); + } finally { activating.delete(key); } + } + + async function dispatch(event: ExtendableMessageEvent): Promise { + const client = await trustedClient(event); + const { operation, value } = event.data; + switch (operation) { + case "getAuthority": { + const entry = await database.get(`authority:${String(value)}`); + return entry ? { ...entry.authority, revoked: entry.revoked } : undefined; + } + case "authority": { + const authority = ReceivingAuthority.dec(ReceivingAuthority.enc(value)); + let replaced = false; + // This transaction deliberately does not wait behind a consent prompt. Core rechecks it after consent. + await database.update(`authority:${authority.productId}`, previous => { + if (previous && (authority.generation < previous.authority.generation || + ((!sameScope(previous.authority, authority) || previous.revoked) && authority.generation <= previous.authority.generation))) { + throw new Error("stale receiving authority generation"); + } + replaced = Boolean(previous && (!sameScope(previous.authority, authority) || previous.revoked)); + return { authority, revoked: false }; + }); + if (replaced) await withCore(async receiver => { + await receiver.receivingRevoke(authority.productId); + await revokeEnrollments(authority.productId); + }); + return; + } + case "revoke": { + const product = String(value); + await database.update(`authority:${product}`, previous => previous ? { ...previous, revoked: true } : undefined); + await withCore(async receiver => { await receiver.receivingRevoke(product); await revokeEnrollments(product); }); + for (const [id, execution] of executions) if (execution.authority.productId === product) executions.delete(id); + return; + } + case "bind": { + const authority = ReceivingAuthority.dec(ReceivingAuthority.enc(value)); + const current = await liveAuthority(authority.productId); + if (!current || !sameScope(current, authority)) throw new Error("receiving authority mismatch"); + if (executions.size >= 512) { + for (const [id, execution] of executions) if (!await scope.clients.get(execution.clientId)) executions.delete(id); + if (executions.size >= 512) throw new Error("receiving execution capacity"); + } + const id = randomReceiverToken(); + executions.set(id, { authority, clientId: client.id, ready: false }); + return id; + } + case "command": { + const execution = boundExecution(value.id, client.id); + if (!Number.isInteger(value.action) || value.action < 2 || value.action > 7 || !(value.payload instanceof Uint8Array) || value.payload.byteLength > 2 * 1024 * 1024) throw new Error("invalid receiving command"); + return withCore(receiver => receiver.commandForExecution(ReceivingAuthority.enc(execution.authority), value.action, value.payload), + client.id, () => executions.get(value.id) === execution); + } + case "unbind": boundExecution(value.id, client.id); executions.delete(value.id); return; + case "ready": { + const execution = boundExecution(value.id, client.id); + const authority = await liveAuthority(execution.authority.productId); + if (!authority || !sameScope(authority, execution.authority)) throw new Error("receiving execution expired"); + execution.ready = true; + event.waitUntil((async () => { + for (const [key, activation] of await database.entries("activation:")) { + if (sameScope(activation.authority, execution.authority)) await deliverActivation(key, activation, client.id); + } + })()); + return; + } + case "refresh": await refreshDestination(); return; + default: throw new Error("unknown receiving operation"); + } + } + + scope.addEventListener("message", event => { + if (event.data?.type !== "truapi:receiving" || !event.ports[0]) return; + const port = event.ports[0]; + event.waitUntil(dispatch(event).then(value => port.postMessage({ ok: true, value }), error => { + port.postMessage({ ok: false, error: error instanceof Error ? error.message : "receiving operation failed" }); + }).finally(() => port.close()).then(() => synchronize())); + }); + + async function receivePush(wake: Wake): Promise { + if (wake.v !== 2 || !/^[a-f0-9]{64}$/.test(wake.deviceId) || !/^[a-f0-9]{64}$/.test(wake.routeToken) || !Number.isSafeInteger(wake.revision)) return; + const enrollment = await database.get(`enrollment:${wake.deviceId}`); + if (!enrollment || enrollment.revoked || enrollment.relayRevision !== wake.revision) return; + const product = enrollment.authority.productId; + const authority = await liveAuthority(product); + if (!authority || !sameScope(authority, enrollment.authority)) return; + const retained = await transport.event(enrollment, wake.messageId); + if (enrollment.routes[retained.watchId] !== wake.routeToken) return; + const events = await withCore(async receiver => { + const current = await database.get(`enrollment:${wake.deviceId}`); + if (!current || current.revoked || current.relayRevision !== wake.revision) return []; + return receivingEventsCodec.dec(await receiver.receivingIngest(product, enrollment.coreRevision, + retained.watchId, retained.genesis, retained.channel, retained.topics, retained.frame)); + }); + // The relay supplies the authenticated frame plus actual source metadata, not a SCALE statement. + // Other signed siblings may become durable events, but this wake may display only its exact event. + const accepted = events.find(event => event.eventId === wake.messageId); + // A retry can find an already-ingested event whose prior OS display explicitly failed. + const eventId = accepted?.eventId ?? wake.messageId; + await new Promise(resolve => setTimeout(resolve, 2100)); + await withCore(async receiver => { + const display = receivingEventCodec.dec(await receiver.receivingPrepareDisplay(product, enrollment.coreRevision, eventId)); + if (!display) return; + const current = await liveAuthority(product); + if (!current || !sameScope(current, enrollment.authority) || !current.osPermission) return; + try { + await scope.registration.showNotification(options.notificationTitle, { + body: "New activity", tag: `truapi:${wake.deviceId}:${eventId}`, + data: { type: "truapi:receiving", authority: enrollment.authority, revision: enrollment.coreRevision.toString(), eventId }, + }); + } catch (error) { + await receiver.receivingCancelDisplay(product, enrollment.coreRevision, eventId); + throw error; + } + await receiver.receivingConfirmDisplay(product, enrollment.coreRevision, eventId); + }); + } + + scope.addEventListener("push", event => { + if (!event.data) return; + let wake: Wake; + try { if (event.data.text().length > 4096) return; wake = event.data.json() as Wake; } catch { return; } + if (wake?.v !== 2) return; + event.waitUntil((async () => { + if (!/^[a-f0-9]{64}$/.test(wake.deviceId) || typeof wake.messageId !== "string" || !/^[A-Za-z0-9._:-]{1,256}$/.test(wake.messageId)) return; + const prefix = `wake:${wake.deviceId}:`; + const pending = await database.entries<{ wake: Wake; receivedAt: number }>(prefix); + pending.sort((left, right) => left[1].receivedAt - right[1].receivedAt); + for (const [key] of pending.slice(0, Math.max(0, pending.length - 3))) await database.put(key, undefined); + const key = `${prefix}${wake.messageId}`; + await database.put(key, { wake, receivedAt: Date.now() }); + try { await receivePush(wake); await database.put(key, undefined); } catch { await queueRetry(); } + await synchronize(); + })()); + }); + + scope.addEventListener("notificationclick", event => { + if (event.notification.data?.type !== "truapi:receiving") return; + event.notification.close(); + event.waitUntil((async () => { + const { authority, revision, eventId } = event.notification.data as { authority: ReceivingAuthority; revision: string; eventId: string }; + const current = await liveAuthority(authority.productId); + if (!current || !sameScope(current, authority)) return; + const preview = await withCore(async receiver => receivingEventCodec.dec(await receiver.receivingValidateActivation(authority.productId, BigInt(revision), eventId))); + if (!preview) return; + const activation: Activation = { authority, revision: BigInt(revision), eventId, expiresAt: preview.expiresAt }; + const key = `activation:${authority.productId}:${eventId}`; + await database.put(key, activation); + for (const execution of executions.values()) { + if (!execution.ready || !sameScope(execution.authority, authority)) continue; + const client = await scope.clients.get(execution.clientId) as WindowClient | undefined; + if (!client) continue; + await client.focus(); + await deliverActivation(key, activation, client.id); + return; + } + const clients = await scope.clients.matchAll({ type: "window", includeUncontrolled: true }); + const host = clients.find(client => client.frameType === "top-level" && new URL(client.url).origin === scope.location.origin); + if (host) { await host.focus(); await deliverActivation(key, activation, host.id); } + else { + const opened = await scope.clients.openWindow(entryUrl.href); + if (opened) await deliverActivation(key, activation, opened.id); + } + })()); + }); + + scope.addEventListener("pushsubscriptionchange", event => { + const changed = event as ExtendableEvent & { oldSubscription?: PushSubscription | null }; + changed.waitUntil((async () => { + const key = changed.oldSubscription?.options.applicationServerKey; + if (key && Notification.permission === "granted") await scope.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key }); + await refreshDestination(); + await synchronize(); + })().catch(() => queueRetry())); + }); + scope.addEventListener("activate", event => { event.waitUntil(refreshDestination().then(() => synchronize()).catch(() => queueRetry())); }); + for (const type of ["sync", "periodicsync"] as const) { + scope.addEventListener(type, ((event: ExtendableEvent & { tag: string }) => { + if (event.tag === "truapi:receiving") event.waitUntil(refreshDestination().then(() => synchronize()).then(async () => { + if (await database.get("syncRequested")) throw new Error("receiving synchronization remains pending"); + })); + }) as EventListener); + } +} diff --git a/js/packages/truapi-host/src/browser-receiving.ts b/js/packages/truapi-host/src/browser-receiving.ts new file mode 100644 index 0000000000..0ee1b5e9b0 --- /dev/null +++ b/js/packages/truapi-host/src/browser-receiving.ts @@ -0,0 +1,135 @@ +import type { ReceivingEvent, ReceivingWatch } from "@parity/truapi"; +import type { ReceivingAuthority } from "./runtime.js"; + +export interface BrowserReceivingClientOptions { + registration: ServiceWorkerRegistration; + /** Trusted host UI. An OS notification grant is not receiving consent. */ + consent(authority: ReceivingAuthority, watches: ReceivingWatch[]): Promise; + /** Return true only after the exact verified product is ready in the unlocked account. */ + activate(authority: ReceivingAuthority, event: ReceivingEvent): Promise; +} + +export interface BrowserReceivingExecution { + command(action: number, payload: Uint8Array): Promise; + ready(): Promise; + close(): void; +} + +export interface BrowserReceivingAuthorityState extends ReceivingAuthority { + revoked: boolean; +} + +export interface BrowserReceivingClient { + getAuthority(productId: string): Promise; + updateAuthority(authority: ReceivingAuthority): Promise; + bindExecution(authority: ReceivingAuthority): Promise; + enableWebPush(vapidPublicKey: string): Promise; + revoke(productId: string): Promise; + refresh(): Promise; + close(): void; +} + +/** Use only in the trusted host page, never in a product iframe. */ +export function createBrowserReceivingClient(options: BrowserReceivingClientOptions): BrowserReceivingClient { + const { registration } = options; + let closed = false; + const executions = new Set(); + async function request(operation: string, value?: unknown): Promise { + if (closed) throw new Error("receiving client closed"); + const worker = registration.active; + if (!worker) throw new Error("receiving service worker unavailable"); + const channel = new MessageChannel(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => finish(new Error("receiving service worker stalled")), 45_000); + function finish(error?: Error, result?: T) { + clearTimeout(timer); + channel.port1.close(); + if (error) reject(error); else resolve(result as T); + } + channel.port1.onmessage = event => { + const reply = event.data; + if (!reply || typeof reply.ok !== "boolean") { finish(new Error("invalid receiving worker reply")); return; } + finish(reply.ok ? undefined : new Error(String(reply.error)), reply.value); + }; + channel.port1.onmessageerror = () => finish(new Error("receiving worker message failed")); + try { worker.postMessage({ type: "truapi:receiving", operation, value }, [channel.port2]); } + catch (error) { finish(error instanceof Error ? error : new Error(String(error))); } + }); + } + const onMessage = (message: MessageEvent) => { + if (closed || message.source !== registration.active || message.data?.type !== "truapi:receiving-host" || !message.ports[0]) return; + const port = message.ports[0]; + const { operation, authority, watches, event } = message.data; + void Promise.resolve().then(() => operation === "consent" ? options.consent(authority, watches) + : operation === "activate" ? options.activate(authority, event) : false) + .then(value => port.postMessage(value === true), () => port.postMessage(false)).finally(() => port.close()); + }; + navigator.serviceWorker.addEventListener("message", onMessage); + const onOnline = () => { void request("refresh").catch(() => {}); }; + window.addEventListener("online", onOnline); + return { + getAuthority: (productId: string) => request("getAuthority", productId), + updateAuthority: (authority: ReceivingAuthority) => request("authority", structuredClone(authority)), + async bindExecution(authority: ReceivingAuthority): Promise { + const snapshot = structuredClone(authority); + let id = await request("bind", snapshot); + executions.add(id); + let disposed = false; + let ready = false; + async function execute(operation: string, value: Record): Promise { + if (disposed || !executions.has(id)) throw new Error("receiving execution closed"); + try { return await request(operation, { ...value, id }); } + catch (error) { + // A missing lease guarantees the command never reached core. Never retry an ambiguous timeout. + if (!(error instanceof Error) || error.message !== "receiving execution unavailable; bind again after worker restart" || !executions.has(id)) throw error; + executions.delete(id); + id = await request("bind", snapshot); + if (disposed || closed) { + void request("unbind", { id }).catch(() => {}); + throw new Error("receiving execution closed"); + } + executions.add(id); + if (ready && operation !== "ready") await request("ready", { id }); + return request(operation, { ...value, id }); + } + } + return { + command: (action, payload) => execute("command", { action, payload }), + ready: async () => { await execute("ready", {}); ready = true; }, + close: () => { + disposed = true; + if (!executions.delete(id)) return; + void request("unbind", { id }).catch(() => {}); + }, + }; + }, + /** Call directly from a user gesture; never from product startup. */ + async enableWebPush(vapidPublicKey: string): Promise { + if (!navigator.userActivation?.isActive) throw new Error("WebPush permission requires a user gesture"); + if (!("PushManager" in window) || !("Notification" in window)) throw new Error("WebPush unavailable"); + const permission = await Notification.requestPermission(); + if (permission !== "granted") { await request("refresh"); throw new Error("notification permission denied"); } + const base64 = vapidPublicKey.replace(/-/g, "+").replace(/_/g, "/"); + const key = Uint8Array.from(atob(base64 + "=".repeat((4 - base64.length % 4) % 4)), value => value.charCodeAt(0)); + if (key.length !== 65 || key[0] !== 4) throw new Error("invalid WebPush VAPID public key"); + const existing = await registration.pushManager.getSubscription(); + const existingKey = existing?.options.applicationServerKey; + if (existing && (!existingKey || new Uint8Array(existingKey).some((byte, index) => byte !== key[index]) || existingKey.byteLength !== key.length)) { + await existing.unsubscribe(); + } + try { await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key }); } + finally { await request("refresh"); } + }, + revoke: (productId: string) => request("revoke", productId), + refresh: () => request("refresh"), + close() { + for (const id of executions) void request("unbind", { id }).catch(() => {}); + executions.clear(); + closed = true; + navigator.serviceWorker.removeEventListener("message", onMessage); + window.removeEventListener("online", onOnline); + }, + }; +} + +export type { ReceivingAuthority } from "./runtime.js"; diff --git a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts index 81ab402feb..e26904e044 100644 --- a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts +++ b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts @@ -12,6 +12,9 @@ import { HostPushNotificationResponse, HostThemeSubscribeItem, RemotePermissionRequest, + ReceivingWatch, + ReceivingReceiptKind, + HostNotificationReceiptResult, } from "@parity/truapi"; import type { GenericError, @@ -29,6 +32,7 @@ import { NativeChatPickedFile, NativeCoinageRequest, NativeCoinageResponse, + ReceivingAuthority, PermissionDecision, PresentedContactProfile, ProductContext, @@ -36,6 +40,8 @@ import { UserConfirmationReview, } from "./generated/host-callbacks.js"; import { makeHostCallbacks, settle } from "./test-support.js"; +import { Vector } from "@parity/truapi/scale"; +import { createNotificationReceiverCallbacks } from "./runtime.js"; // The generated `createWasmRawCallbacks` adapter speaks the symmetric SCALE // byte boundary: codec-typed requests arrive as `Uint8Array` and are decoded @@ -67,6 +73,70 @@ it("preserves one-use permission decisions across the WASM callback", async () = } }); +it("keeps receiving authority host-owned and preserves forever mute across SCALE", async () => { + const authority: ReceivingAuthority = { + productId: "playground.dot", account: "11".repeat(32), environment: "paseo", + artifact: "22".repeat(32), genesis: "33".repeat(32), generation: 7n, + osPermission: true, transportReady: false, + }; + const watches: ReceivingWatch[] = [{ + id: "group", genesis: authority.genesis, channel: "44".repeat(32), + topics: ["55".repeat(32)], senders: ["66".repeat(32)], + expiresAt: 1_800_000_000_000n, mutedUntil: (1n << 64n) - 1n, route: "/group", + }]; + const calls: unknown[] = []; + const raw = createWasmRawCallbacks(makeHostCallbacks({ + notifications: { + receiverAuthority: async (productId) => { + calls.push(productId); + return authority; + }, + receiverConsent: async (scope, policies) => { + calls.push({ scope, policies }); + return false; + }, + receiverCommand: async (productId, action, payload) => { + calls.push({ productId, action, payload }); + throw new Error("owner unavailable"); + }, + }, + })); + const encoded = await raw.receiverAuthority("playground.dot"); + expect(ReceivingAuthority.dec(encoded!)).toEqual(authority); + expect(await raw.receiverConsent(ReceivingAuthority.enc(authority), Vector(ReceivingWatch).enc(watches))).toBe(false); + const payload = new Uint8Array([1, 2]); + await expect(raw.receiverCommand("playground.dot", 4, payload)).rejects.toThrow("owner unavailable"); + expect(calls).toEqual([ + "playground.dot", { scope: authority, policies: watches }, + { productId: "playground.dot", action: 4, payload }, + ]); +}); + +it("does not claim receiving support when callbacks are absent", async () => { + const raw = createWasmRawCallbacks(makeHostCallbacks()); + expect(await raw.receiverAuthority("playground.dot")).toBeUndefined(); + expect(await raw.receiverCommand("playground.dot", 2, new Uint8Array())).toBeUndefined(); + await expect(raw.receiverChanged()).rejects.toThrow("background receiving unsupported"); + const standalone = createNotificationReceiverCallbacks({ + readReceivingState: () => undefined, + writeReceivingState: () => { throw new Error("storage unavailable"); }, + }); + expect(await standalone.receiverAuthority("playground.dot")).toBeUndefined(); + await expect(standalone.receiverConsent(new Uint8Array(), new Uint8Array())).rejects.toThrow("background receiving unsupported"); + await expect(standalone.receiverChanged()).rejects.toThrow("background receiving unsupported"); + await expect(standalone.writeReceivingState(new Uint8Array())).rejects.toThrow("storage unavailable"); +}); + +it("preserves confirmed versus pending display in the receipt wire payload", () => { + expect(ReceivingReceiptKind.enc("Foreground")).toEqual(new Uint8Array([0])); + expect(ReceivingReceiptKind.enc("Read")).toEqual(new Uint8Array([1])); + expect(ReceivingReceiptKind.enc("Displayed")).toEqual(new Uint8Array([2])); + expect(HostNotificationReceiptResult.enc({ displayed: false, displayPending: true })) + .toEqual(new Uint8Array([0, 1])); + expect(HostNotificationReceiptResult.dec(new Uint8Array([1, 0]))) + .toEqual({ displayed: true, displayPending: false }); +}); + const defaultTheme = (variant: ThemeVariant): HostThemeSubscribeItemValue => ({ name: { tag: "Default" }, variant, diff --git a/js/packages/truapi-host/src/runtime.ts b/js/packages/truapi-host/src/runtime.ts index fe6553fd1e..1de44fcca8 100644 --- a/js/packages/truapi-host/src/runtime.ts +++ b/js/packages/truapi-host/src/runtime.ts @@ -5,6 +5,12 @@ import type { RendererNode, WireProvider, } from "@parity/truapi"; +import { ReceivingEvent, ReceivingWatch } from "@parity/truapi"; +import { Option, Vector } from "@parity/truapi/scale"; +import { + ReceivingAuthority, + ReceivingRegistration, +} from "./generated/host-callbacks.js"; import { CoreStorageKey as GeneratedCoreStorageKey } from "./generated/host-callbacks.js"; import type { CoreAdmin, @@ -51,6 +57,77 @@ export interface NativeChatContactsSnapshot { */ export type Awaitable = T | Promise; +/** Canonical SCALE results returned by resident receiving runtime hooks. */ +export const receivingRegistrationsCodec = Vector(ReceivingRegistration); +export const receivingEventsCodec = Vector(ReceivingEvent); +export const receivingEventCodec = Option(ReceivingEvent); +const receivingWatchesCodec = Vector(ReceivingWatch); + +/** Minimal host-owned callbacks for a wallet-free service-worker receiver. */ +export interface NotificationReceiverCallbacks { + /** Resolve current verified artifact/account state, never product message claims. */ + receiverAuthority?(productId: string): Awaitable; + /** Separate receiving consent, not an OS permission or relay acknowledgement. */ + receiverConsent?(authority: ReceivingAuthority, watches: ReceivingWatch[]): Awaitable; + /** Wake asynchronous transport work; never await remote synchronization. */ + receiverChanged?(): Awaitable; + readReceivingState(): Awaitable; + /** Atomically replace the private ledger. Only one receiver may write it. */ + writeReceivingState(bytes: Uint8Array): Awaitable; +} + +/** Encode only the canonical domain records at the standalone WASM boundary. */ +export function createNotificationReceiverCallbacks(callbacks: NotificationReceiverCallbacks) { + return { + receiverAuthority: async (productId: string) => { + const authority = await callbacks.receiverAuthority?.(productId); + return authority === undefined ? undefined : ReceivingAuthority.enc(authority); + }, + receiverConsent: async (authority: Uint8Array, watches: Uint8Array) => { + if (!callbacks.receiverConsent) throw new Error("background receiving unsupported"); + return callbacks.receiverConsent(ReceivingAuthority.dec(authority), receivingWatchesCodec.dec(watches)); + }, + receiverChanged: async () => { + if (!callbacks.receiverChanged) throw new Error("background receiving unsupported"); + return callbacks.receiverChanged(); + }, + readReceivingState: async () => callbacks.readReceivingState(), + writeReceivingState: async (bytes: Uint8Array) => callbacks.writeReceivingState(bytes), + }; +} + +/** Raw host-only receiving hooks on both full resident and standalone WASM cores. + * Products must use Notifications actions, never these host-authority hooks. + */ +export interface RawReceivingRuntime { + receivingPending(): Promise; + receivingSynchronized(productId: string, revision: bigint): Promise; + receivingIngest( + productId: string, revision: bigint, watchId: string, + actualGenesis: string, actualChannel: string, actualTopics: string[], frame: Uint8Array, + ): Promise; + receivingIngestStatement( + productId: string, revision: bigint, watchId: string, + actualGenesis: string, statement: Uint8Array, + ): Promise; + receivingPrepareDisplay(productId: string, revision: bigint, eventId: string): Promise; + receivingConfirmDisplay(productId: string, revision: bigint, eventId: string): Promise; + /** Clear a reservation after explicit display failure, never after an unknown outcome. */ + receivingCancelDisplay(productId: string, revision: bigint, eventId: string): Promise; + receivingValidateActivation(productId: string, revision: bigint, eventId: string): Promise; + receivingActivate(productId: string, revision: bigint, eventId: string): Promise; + receivingRevoke(productId: string): Promise; + receivingMarkTransportChanged(productId: string): Promise; +} + +/** Standalone commands carry the immutable authority captured by the trusted execution channel. + * Authority bytes encode ReceivingAuthority; response is Result. + */ +export interface RawNotificationReceiver extends RawReceivingRuntime { + commandForExecution(authority: Uint8Array, action: number, payload: Uint8Array): Promise; + free(): void; +} + /** * Open a JSON-RPC connection for `genesisHash`. The wasm bridge passes * `onResponse` so the host can push JSON-RPC replies back asynchronously. diff --git a/js/packages/truapi-host/src/test-support.ts b/js/packages/truapi-host/src/test-support.ts index 43348e44c8..77e6fe2825 100644 --- a/js/packages/truapi-host/src/test-support.ts +++ b/js/packages/truapi-host/src/test-support.ts @@ -27,6 +27,10 @@ export function makeHostCallbacks( notifications: { pushNotification: async () => ({ id: 0 }), cancelNotification: async () => {}, + receiverAuthority: async () => undefined, + receiverConsent: async () => { throw new Error("background receiving unsupported"); }, + receiverChanged: async () => { throw new Error("background receiving unsupported"); }, + receiverCommand: async () => undefined, }, permissions: { devicePermission: async () => "Deny", diff --git a/js/packages/truapi-host/src/wasm-module.ts b/js/packages/truapi-host/src/wasm-module.ts index c7c8b3c535..9d4cc4e2d8 100644 --- a/js/packages/truapi-host/src/wasm-module.ts +++ b/js/packages/truapi-host/src/wasm-module.ts @@ -7,6 +7,7 @@ import type { PermissionAuthorizationRuntime } from "./worker-permission-authori import type { LocalIdentity } from "./worker-protocol.js"; import type { WalletAllowanceSnapshot } from "./wallet-allowances.js"; import type { NativeChatContactsSnapshot } from "./runtime.js"; +import type { RawNotificationReceiver, RawReceivingRuntime } from "./runtime.js"; /** Cancellable handle on one live render stream inside the core. */ export interface WorkerRendererSubscription { @@ -49,7 +50,7 @@ export interface WorkerProductRuntime { export type WorkerTransition = "Start" | "Stop"; /** Runtime operations shared by paired and browser-local signing hosts. */ -export interface WorkerHostRuntime extends PermissionAuthorizationRuntime { +export interface WorkerHostRuntime extends PermissionAuthorizationRuntime, RawReceivingRuntime { productRuntime( product: unknown, coreCallbacks: unknown, @@ -130,6 +131,8 @@ export interface WorkerSigningHostRuntime extends WorkerHostRuntime { /** Module surface the wasm-pack glue exports. */ export interface WasmModuleShape { default: (input?: unknown) => Promise; + /** Wallet-free receiver for the host's single durable service-worker owner. */ + WasmNotificationReceiver: new (callbacks: unknown) => RawNotificationReceiver; WasmPairingHostRuntime: new ( callbacks: unknown, hostConfig: unknown, diff --git a/js/packages/truapi-host/src/wasm/web/truapi_server.d.ts b/js/packages/truapi-host/src/wasm/web/truapi_server.d.ts index 249b2b6f9e..5d8474839d 100644 --- a/js/packages/truapi-host/src/wasm/web/truapi_server.d.ts +++ b/js/packages/truapi-host/src/wasm/web/truapi_server.d.ts @@ -13,6 +13,7 @@ export default init; export const WasmPairingHostRuntime: WasmModuleShape["WasmPairingHostRuntime"]; export const WasmSigningHostRuntime: WasmModuleShape["WasmSigningHostRuntime"]; export const WasmProductRuntime: WasmModuleShape["WasmProductRuntime"]; +export const WasmNotificationReceiver: WasmModuleShape["WasmNotificationReceiver"]; export const setLogLevel: (level: string) => void; export const deriveProductAccountPublicKey: WasmModuleShape["deriveProductAccountPublicKey"]; export const productAccountAddress: WasmModuleShape["productAccountAddress"]; diff --git a/js/packages/truapi-host/src/web/create-mock-host.ts b/js/packages/truapi-host/src/web/create-mock-host.ts index 0b245b219d..2d68e727db 100644 --- a/js/packages/truapi-host/src/web/create-mock-host.ts +++ b/js/packages/truapi-host/src/web/create-mock-host.ts @@ -1163,6 +1163,10 @@ export function createMockHost(config: MockHostConfig = {}): MockHost { const entry = pushedNotifications.find((n) => n.id === id); if (entry) entry.cancelled = true; }, + async receiverAuthority() { return undefined; }, + async receiverConsent() { throw new Error("background receiving unsupported"); }, + async receiverChanged() { throw new Error("background receiving unsupported"); }, + async receiverCommand() { return undefined; }, }, permissions: { diff --git a/js/packages/truapi/README.md b/js/packages/truapi/README.md index 766fb5e8f4..c42d7f3ed4 100644 --- a/js/packages/truapi/README.md +++ b/js/packages/truapi/README.md @@ -41,6 +41,81 @@ Request methods take the inner request value directly. The transport adds the wi Requests reject with `RequestTimeoutError` when no matching response arrives within 120 seconds. Pass `{ requestTimeoutMs }` to `createTransport` to select a different positive deadline. +## Authenticated notification envelopes + +`@parity/truapi/notification-envelope` provides synchronous Ed25519/SHA-256 helpers +for browsers, Node and Bun without requiring Web Crypto: + +- `signNotificationHeader(metadata, opaqueBytes, seed32)` signs a header that an + application can add to its existing standard Envelope as a `truapiNotification` + text assertion with `JSON.stringify(header)`. Existing application assertions and + byte leaves need not change. +- `signNotificationEnvelope(metadata, opaqueBytes, seed32)` emits a minimal standard + carrier containing the byte leaf and its reserved assertion. +- `verifyNotificationEnvelopes(frame, actualGenesis, actualChannel, actualTopics, nowMs)` + returns all eligible `{ header, carrier }` candidates. Invalid candidate metadata + or proofs are omitted; malformed/ambiguous containers throw. +- `authenticateNotificationEnvelopes(frame, actualGenesis, actualChannel, actualTopics)` + verifies stored history without current-time notification eligibility. +- `authenticateNotificationHeader(json, opaqueBytes)` authenticates optional stored + metadata directly, without constructing or copying a carrier. It checks strict + JSON, static bounds, byte digest and signature only. It establishes **neither + actual source nor current-time eligibility, enrollment or sender approval**. + Hosts and relays must use the whole-carrier verification APIs above. +- The singular `verifyNotificationEnvelope`, `authenticateNotificationEnvelope` and + `decodeNotificationEnvelope` require exactly one candidate. Decode does not verify + authenticity. `encodeNotificationEnvelope(header, opaqueBytes)` only validates + structure. `notificationSigningBytes(header)` exposes the signed tuple. + +Callers must enforce product binding, enrollment, approved senders, mute and replay +policy. A watch's topics must be a subset of the **signed header topics**, which +must themselves be present in the actual source topics. Unsigned extra actual topics +never broaden the authenticated watch scope. Genesis and channel match exactly. + +The container uses the base [Gordian Envelope grammar](https://datatracker.ietf.org/doc/html/draft-mcnally-envelope-12#section-3): +outer CBOR tag 200, leaf tag 201, node arrays and single-entry assertion maps. +The parser traverses standard nodes without interpreting application predicates. +Each reserved assertion contains strict JSON text; its `ciphertextDigest` must +resolve to a byte leaf somewhere in the same container. Byte leaves are indexed by +SHA-256 once per traversal, so a nested assertion can authenticate an existing +opaque leaf without decorating or copying it. Application fields unrelated to +the reserved assertion are not authenticated by this header. + +The notification profile accepts canonical definite-length integer-only dCBOR +leaves, NFC text, sorted map keys and digest-sorted unique node assertions. +Floats and unsupported Envelope extensions are rejected. Limits are 256 KiB for +the complete frame (`MAX_FULL_FRAME_BYTES`), 240 KiB per referenced byte leaf, +16 KiB per JSON header, 32 candidates globally, 128 assertions per node, +4096 CBOR items and depth 16. Duplicate reserved assertions on one node are rejected. +`isNotificationEnvelope(frame)` performs bounded structural traversal, returning +false for valid unmarked base Envelopes and non-Envelope bytes, and true when a +reserved assertion exists even if its JSON/proof is invalid. Malformed containers +throw: callers must drop them, never fall back to another authentication path. +Returned byte witnesses are copied to prevent later input mutation changing them. + +Header fields are exactly `v`, `product`, `genesis`, `channel`, `topics`, `eventId`, +`createdAt`, `expiresAt`, `ciphertextDigest`, `senderKey` and `signature`. +Duplicate, missing and unknown fields are rejected. Version is 1; product matches +`[a-z0-9._-]{1,128}`. Hashes, channel, topics and raw keys are 32-byte lowercase hex +without `0x`; signatures are 64-byte lowercase hex. Signed topics are ordered and +distinct, with one to four entries. Safe-integer epoch-millisecond timestamps have +exclusive expiry, a maximum 24-hour lifetime and a maximum 60-second future skew. +JSON integer spellings cannot contain a minus sign, decimal point or exponent. + +The Ed25519 acceptance profile matches Rust's `ed25519-dalek::verify_strict` with +canonical point encodings: valid canonical A and R, scalar S below the subgroup +order, no small-order A or R, and the exact uncofactored equation +`R = [S]B - [SHA512(R || A || signingBytes) mod L]A`. Mixed-order points are not +blanket-rejected if they satisfy this exact equation. The JS helper uses Noble's +point/scalar/hash primitives directly because Noble's `verify`, even with +`zip215: false`, checks a cofactored equation and therefore has different acceptance. + +Signed bytes are whitespace-free UTF-8 JSON of +`["truapi:notification:v1",v,product,genesis,channel,topics,eventId,createdAt,expiresAt,ciphertextDigest,senderKey]`. +This matches the Rust runtime verifier. Signing metadata omits `v`, +`ciphertextDigest`, `senderKey` and `signature`, which the helper derives. +No product codec, decryption key or notification permission is provided. + ## Subscriptions Streaming methods return a small Observable-compatible object: diff --git a/js/packages/truapi/package.json b/js/packages/truapi/package.json index 9e5f3d7402..eea1d1d87f 100644 --- a/js/packages/truapi/package.json +++ b/js/packages/truapi/package.json @@ -43,6 +43,10 @@ "types": "./dist/scale.d.ts", "import": "./dist/scale.js" }, + "./notification-envelope": { + "types": "./dist/notification-envelope.d.ts", + "import": "./dist/notification-envelope.js" + }, "./wire-table": { "types": "./dist/generated/wire-table.d.ts", "import": "./dist/generated/wire-table.js" @@ -91,6 +95,7 @@ "typescript": "^6.0" }, "dependencies": { + "@noble/curves": "^2.0.1", "@noble/hashes": "^2.2.0", "neverthrow": "^8.2.0", "scale-ts": "^1.6.1" diff --git a/js/packages/truapi/src/notification-container.ts b/js/packages/truapi/src/notification-container.ts new file mode 100644 index 0000000000..2d0336af4c --- /dev/null +++ b/js/packages/truapi/src/notification-container.ts @@ -0,0 +1,159 @@ +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex, concatBytes } from "@noble/hashes/utils.js"; + +// Base Gordian Envelope grammar, independently implemented from +// https://datatracker.ietf.org/doc/html/draft-mcnally-envelope-12#section-3 +// This notification profile accepts integer-only dCBOR leaves (no floats). +export const MAX_CONTAINER_BYTES = 256 * 1024; +export const NOTIFICATION_PREDICATE = "truapiNotification"; +export const MAX_NOTIFICATION_CANDIDATES = 32; +const encoder = new TextEncoder(); +const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); +const MAX_DEPTH = 16; +const MAX_ITEMS = 4096; +const MAX_ASSERTIONS = 128; +interface Item { + major: number; + argument: number; + start: number; + end: number; + children: Item[]; + bytes?: Uint8Array; + text?: string; +} +function compareBytes(left: Uint8Array, right: Uint8Array): number { + for (let index = 0; index < Math.min(left.length, right.length); index++) { + if (left[index] !== right[index]) return left[index]! - right[index]!; + } + return left.length - right.length; +} + +/** Canonical definite-length, bounded integer-only dCBOR reader. */ +function readContainer(bytes: Uint8Array): Item { + if (bytes.length > MAX_CONTAINER_BYTES) throw new Error("notification container too large"); + let offset = 0; + let remainingItems = MAX_ITEMS; + function read(depth: number): Item { + if (depth > MAX_DEPTH || --remainingItems < 0 || offset >= bytes.length) throw new Error("invalid CBOR bounds"); + const start = offset; + const initial = bytes[offset++]!; + const major = initial >>> 5; + const additional = initial & 31; + if (additional >= 28 || (major === 7 && additional > 23)) throw new Error("unsupported CBOR value"); + let argument = additional; + if (additional >= 24) { + const size = 2 ** (additional - 24); + if (offset + size > bytes.length) throw new Error("truncated CBOR argument"); + argument = 0; + for (let index = 0; index < size; index++) argument = argument * 256 + bytes[offset++]!; + if (!Number.isSafeInteger(argument) || argument < [24, 256, 65536, 4294967296][additional - 24]!) throw new Error("noncanonical CBOR integer"); + } + const item: Item = { major, argument, start, end: 0, children: [] }; + if (major === 2 || major === 3) { + if (argument > bytes.length - offset) throw new Error("truncated CBOR bytes"); + item.bytes = bytes.subarray(offset, offset + argument); + offset += argument; + if (major === 3) { + item.text = decoder.decode(item.bytes); + if (item.text.normalize("NFC") !== item.text) throw new Error("noncanonical CBOR text"); + } + } else if (major === 4 || major === 5 || major === 6) { + const count = major === 6 ? 1 : argument * (major === 5 ? 2 : 1); + if (count > remainingItems) throw new Error("CBOR item limit"); + for (let index = 0; index < count; index++) item.children.push(read(depth + 1)); + if (major === 5) { + for (let index = 2; index < item.children.length; index += 2) { + const prior = item.children[index - 2]!; + const current = item.children[index]!; + if (compareBytes(bytes.subarray(prior.start, prior.end), bytes.subarray(current.start, current.end)) >= 0) throw new Error("noncanonical CBOR map"); + } + } + } else if (major === 7 && ![20, 21, 22].includes(argument)) { + throw new Error("unsupported CBOR simple value"); + } + item.end = offset; + return item; + } + const root = read(0); + if (offset !== bytes.length) throw new Error("trailing CBOR data"); + return root; +} +interface Envelope { + kind: "leaf" | "node" | "assertion" | "elided" | "wrapped"; + digest: Uint8Array; + bytes?: Uint8Array; + text?: string; + predicate?: Envelope; + object?: Envelope; + assertions?: Envelope[]; +} + +/** Inspect bounded standard structure without interpreting application assertions. */ +export function decodeNotificationContainer(bytes: Uint8Array): { headers: string[]; subjects: Map } { + const root = readContainer(bytes); + if (root.major !== 6 || root.argument !== 200) throw new Error("not a Gordian Envelope"); + const headers: string[] = []; + const subjects = new Map(); + function envelope(item: Item): Envelope { + if (item.major === 6 && item.argument === 201) { + const value = item.children[0]!; + if (value.major === 2) { + const digest = bytesToHex(sha256(value.bytes!)); + const prior = subjects.get(digest); + if (prior && compareBytes(prior, value.bytes!) !== 0) throw new Error("contradictory byte witness"); + subjects.set(digest, value.bytes!); + } + return { kind: "leaf", digest: sha256(bytes.subarray(value.start, value.end)), bytes: value.major === 2 ? value.bytes : undefined, text: value.text }; + } + if (item.major === 2 && item.argument === 32) return { kind: "elided", digest: item.bytes! }; + if (item.major === 5 && item.argument === 1) { + const predicate = envelope(item.children[0]!); + const object = envelope(item.children[1]!); + if (predicate.text === NOTIFICATION_PREDICATE && (predicate.kind !== "leaf" || object.kind !== "leaf" || object.text === undefined)) throw new Error("ambiguous notification assertion"); + if (predicate.text === NOTIFICATION_PREDICATE) { + if (headers.length >= MAX_NOTIFICATION_CANDIDATES) throw new Error("notification candidate limit"); + headers.push(object.text!); + } + return { kind: "assertion", predicate, object, digest: sha256(concatBytes(predicate.digest, object.digest)) }; + } + if (item.major === 6 && item.argument === 200) { + const child = envelope(item.children[0]!); + return { kind: "wrapped", digest: sha256(child.digest) }; + } + if (item.major === 4 && item.argument >= 2 && item.argument <= MAX_ASSERTIONS + 1) { + const subject = envelope(item.children[0]!); + const assertions = item.children.slice(1).map(envelope); + let reservedCount = 0; + for (let index = 0; index < assertions.length; index++) { + const assertion = assertions[index]!; + if (assertion.kind !== "assertion" && assertion.kind !== "elided") throw new Error("invalid Envelope assertion"); + if (index > 0 && compareBytes(assertions[index - 1]!.digest, assertion.digest) >= 0) throw new Error("noncanonical Envelope assertion order"); + if (assertion.predicate?.text === NOTIFICATION_PREDICATE && ++reservedCount > 1) throw new Error("duplicate notification assertion"); + } + return { kind: "node", bytes: subject.bytes, text: subject.text, assertions, digest: sha256(concatBytes(subject.digest, ...assertions.map((assertion) => assertion.digest))) }; + } + throw new Error("unsupported Envelope structure"); + } + envelope(root.children[0]!); + return { headers, subjects }; +} + +function cborHead(major: number, argument: number): Uint8Array { + if (argument < 24) return Uint8Array.of((major << 5) | argument); + const size = argument <= 255 ? 1 : argument <= 65535 ? 2 : 4; + const result = new Uint8Array(size + 1); + result[0] = (major << 5) | (size === 1 ? 24 : size === 2 ? 25 : 26); + for (let index = size; index > 0; index--) { result[index] = argument & 255; argument = Math.floor(argument / 256); } + return result; +} + +/** Encode the minimal standard carrier, leaving application assertions to its owner. */ +export function encodeNotificationContainer(headerJson: string, carrier: Uint8Array): Uint8Array { + const predicate = encoder.encode(NOTIFICATION_PREDICATE); + const header = encoder.encode(headerJson); + return concatBytes( + Uint8Array.of(0xd8, 200, 0x82, 0xd8, 201), cborHead(2, carrier.length), carrier, + Uint8Array.of(0xa1, 0xd8, 201), cborHead(3, predicate.length), predicate, + Uint8Array.of(0xd8, 201), cborHead(3, header.length), header, + ); +} diff --git a/js/packages/truapi/src/notification-envelope.test.ts b/js/packages/truapi/src/notification-envelope.test.ts new file mode 100644 index 0000000000..56e20bfe78 --- /dev/null +++ b/js/packages/truapi/src/notification-envelope.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "bun:test"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { concatBytes, hexToBytes } from "@noble/hashes/utils.js"; +import { + authenticateNotificationEnvelope, authenticateNotificationHeader, decodeNotificationEnvelope, encodeNotificationEnvelope, + isNotificationEnvelope, notificationSigningBytes, signNotificationEnvelope, signNotificationHeader, + verifyNotificationEnvelope, verifyNotificationEnvelopes, MAX_CARRIER_BYTES, MAX_FULL_FRAME_BYTES, + MAX_HEADER_BYTES, type NotificationHeader, +} from "./notification-envelope.js"; + +// Independent OpenSSL Ed25519 vector: raw seed 00..1f, subject 'abc'. +const header: NotificationHeader = { + v: 1, product: "example.paseo", genesis: "11".repeat(32), channel: "55".repeat(32), + topics: ["22".repeat(32), "33".repeat(32)], eventId: "44".repeat(32), + createdAt: 1700000000000, expiresAt: 1700000060000, + ciphertextDigest: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + senderKey: "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8", + signature: "6bc192199982a1b8e850b66b1f0cd85035354e0b788edecddfad7505da94c7347447b5fa4c9a64647045eea6d52e4aecec14dcb16ea64de317b1b3e76b0f830b", +}; +const seed = hexToBytes("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"); +const encoder = new TextEncoder(); +const carrier = encoder.encode("abc"); +const { product, genesis, channel, topics, eventId, createdAt, expiresAt } = header; +const metadata = { product, genesis, channel, topics, eventId, createdAt, expiresAt }; + +// Independent test-only implementation of the standard base Envelope grammar. +interface Element { bytes: Uint8Array; digest: Uint8Array } +function head(major: number, length: number): Uint8Array { + if (length < 24) return Uint8Array.of(major * 32 + length); + if (length < 256) return Uint8Array.of(major * 32 + 24, length); + if (length < 65536) return Uint8Array.of(major * 32 + 25, length >>> 8, length & 255); + return Uint8Array.of(major * 32 + 26, length >>> 24, length >>> 16 & 255, length >>> 8 & 255, length & 255); +} +function leaf(value: string | Uint8Array): Element { + const bytes = typeof value === "string" ? encoder.encode(value) : value; + const encoded = concatBytes(head(typeof value === "string" ? 3 : 2, bytes.length), bytes); + return { bytes: concatBytes(Uint8Array.of(0xd8, 201), encoded), digest: sha256(encoded) }; +} +function assertion(predicate: string, object: Element): Element { + const key = leaf(predicate); + return { bytes: concatBytes(Uint8Array.of(0xa1), key.bytes, object.bytes), digest: sha256(concatBytes(key.digest, object.digest)) }; +} +function node(subject: Element, assertions: Element[]): Element { + const sorted = [...assertions].sort((left, right) => { + for (let index = 0; index < 32; index++) if (left.digest[index] !== right.digest[index]) return left.digest[index]! - right.digest[index]!; + return 0; + }); + return { bytes: concatBytes(head(4, sorted.length + 1), subject.bytes, ...sorted.map((entry) => entry.bytes)), + digest: sha256(concatBytes(subject.digest, ...sorted.map((entry) => entry.digest))) }; +} +function frame(json = JSON.stringify(header), body = carrier): Uint8Array { + return concatBytes(Uint8Array.of(0xd8, 200), node(leaf(body), [assertion("truapiNotification", leaf(json))]).bytes); +} +function verify(bytes: Uint8Array) { + return verifyNotificationEnvelope(bytes, genesis, channel, topics, createdAt); +} + +describe("standard generic notification carriers", () => { + it("matches the independent fixed signing bytes, signature and standard encoding used by Rust", () => { + expect(new TextDecoder().decode(notificationSigningBytes(header))).toBe('["truapi:notification:v1",1,"example.paseo","' + genesis + '","' + channel + '",["' + topics[0] + '","' + topics[1] + '"],"' + eventId + '",1700000000000,1700000060000,"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad","03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8"]'); + expect(signNotificationHeader(metadata, carrier, seed)).toEqual(header); + const signed = signNotificationEnvelope(metadata, carrier, seed); + expect(decodeNotificationEnvelope(signed).header).toEqual(header); + expect(verify(frame()).carrier).toEqual(carrier); + expect(encodeNotificationEnvelope(header, carrier)).toEqual(frame()); + }); + + it("matches Dalek's exact equation without blanket mixed-torsion rejection", () => { + // Independently constructed with scalar a=r=1 and order-two T=(0,-1). + // R=B+T, A=B satisfies only the cofactored equation and MUST fail. + const cofactored = { ...header, + senderKey: "5866666666666666666666666666666666666666666666666666666666666666", + signature: "95999999999999999999999999999999999999999999999999999999999999994d0d311b42ae0fbd5231e2b7e106d734ca5e5045ba0882ac54c3f232e5718300", + }; + expect(() => authenticateNotificationHeader(JSON.stringify(cofactored), carrier)).toThrow(); + expect(() => verify(frame(JSON.stringify(cofactored)))).toThrow(); + // A=B+T, R=B and even reduced challenge satisfies the exact equation. + const mixedKey = { ...header, eventId: "00".repeat(31) + "04", + senderKey: "9599999999999999999999999999999999999999999999999999999999999999", + signature: "58666666666666666666666666666666666666666666666666666666666666663114ba2ce5c96de9e15fbc99e889f60672480566a4420d0d7806399239ed5a06", + }; + expect(authenticateNotificationHeader(JSON.stringify(mixedKey), carrier)).toEqual(mixedKey); + expect(verify(frame(JSON.stringify(mixedKey))).header).toEqual(mixedKey); + }); + + it("authenticates optional stored metadata without an actual-source or clock claim", () => { + expect(authenticateNotificationHeader(JSON.stringify(header), carrier)).toEqual(header); + expect(() => authenticateNotificationHeader(JSON.stringify(header), encoder.encode("different"))).toThrow(); + expect(() => authenticateNotificationHeader(JSON.stringify({ ...header, eventId: "66".repeat(32) }), carrier)).toThrow(); + expect(() => authenticateNotificationHeader(JSON.stringify(header).replace('"v":1', '"v":1,"v":1'), carrier)).toThrow(); + expect(() => authenticateNotificationHeader(JSON.stringify(header), new Uint8Array(MAX_CARRIER_BYTES + 1))).toThrow(); + expect(() => verifyNotificationEnvelope(frame(), genesis, "00".repeat(32), topics, expiresAt)).toThrow(); + }); + + it("authenticates history without making expired candidates notification-eligible", () => { + const bytes = frame(); + const authenticated = authenticateNotificationEnvelope(bytes, genesis, channel, topics); + expect(() => verifyNotificationEnvelope(bytes, genesis, channel, topics, expiresAt)).toThrow(); + bytes[7] ^= 1; + expect(authenticated.carrier).toEqual(carrier); + }); + + it("resolves a byte witness elsewhere without interpreting application predicates", () => { + const prior = node(leaf("unrelated subject"), [ + assertion("opaque-slot", leaf(carrier)), assertion("truapiNotification", leaf(JSON.stringify(header))), + ]); + const outer = node(leaf("control"), [assertion("application-defined", prior)]); + const bytes = concatBytes(Uint8Array.of(0xd8, 200), outer.bytes); + expect(isNotificationEnvelope(bytes)).toBe(true); + expect(verify(bytes).carrier).toEqual(carrier); + expect(verifyNotificationEnvelopes(bytes, genesis, channel, ["66".repeat(32), ...topics], createdAt)).toHaveLength(1); + expect(verifyNotificationEnvelopes(bytes, genesis, channel, [topics[0]!], createdAt)).toEqual([]); + }); + + it("does not let an invalid sibling suppress a valid authenticated candidate", () => { + const valid = node(leaf(carrier), [assertion("truapiNotification", leaf(JSON.stringify(header)))]); + const forged = node(leaf("another subject"), [assertion("truapiNotification", leaf(JSON.stringify({ ...header, signature: "00".repeat(64) })))]); + const bytes = concatBytes(Uint8Array.of(0xd8, 200), node(leaf("container"), [assertion("first", valid), assertion("second", forged)]).bytes); + expect(verifyNotificationEnvelopes(bytes, genesis, channel, topics, createdAt)).toHaveLength(1); + expect(() => verify(bytes)).toThrow("exactly one"); + expect(verifyNotificationEnvelopes(frame(JSON.stringify(header), encoder.encode("wrong")), genesis, channel, topics, createdAt)).toEqual([]); + }); + + it("discriminates unmarked carriers and fails closed on structural ambiguities", () => { + const unmarked = concatBytes(Uint8Array.of(0xd8, 200), node(leaf(carrier), [assertion("scheme", leaf("opaque"))]).bytes); + expect(isNotificationEnvelope(unmarked)).toBe(false); + expect(isNotificationEnvelope(encoder.encode("ordinary bytes"))).toBe(false); + expect(isNotificationEnvelope(frame("not valid JSON"))).toBe(true); + const duplicate = concatBytes(Uint8Array.of(0xd8, 200), node(leaf(carrier), [ + assertion("truapiNotification", leaf(JSON.stringify(header))), assertion("truapiNotification", leaf("different")), + ]).bytes); + expect(() => isNotificationEnvelope(duplicate)).toThrow(); + expect(() => verify(duplicate)).toThrow(); + expect(() => verify(concatBytes(frame(), Uint8Array.of(0)))).toThrow(); + expect(() => verify(concatBytes(Uint8Array.of(0xd9, 0, 200), frame().subarray(2)))).toThrow(); + const malformedUtf8 = frame(); malformedUtf8[malformedUtf8.indexOf(0x7b) + 1] = 0xff; + expect(() => verify(malformedUtf8)).toThrow(); + }); + + it("rejects duplicate/escaped duplicate/unknown/missing fields and noninteger JSON", () => { + const json = JSON.stringify(header); + for (const invalid of [ + json.replace('"v":1', '"v":1,"v":1'), json.replace('"v":1', '"v":1,"\\u0076":1'), + json.replace('"v":1', '"v":1,"extra":false'), json.replace('"v":1,', ""), + json.replace('"v":1', '"v":1.0'), json.replace('"v":1', '"v":1e0'), json.replace('"v":1', '"v":-0'), + ]) expect(() => verify(frame(invalid))).toThrow(); + }); + + it("rejects invalid metadata, source, proof and lifetime", () => { + for (const patch of [ + { product: "😀" }, { product: "A" }, { product: "x".repeat(129) }, { product: "" }, + { genesis: "AA".repeat(32) }, { channel: "0x" + channel }, { eventId: "66".repeat(32) }, + { topics: [] }, { topics: [topics[0], topics[0]] }, { topics: Array(5).fill(topics[0]) }, + { createdAt: Number.MAX_SAFE_INTEGER + 1 }, { createdAt: -1 }, { expiresAt: createdAt }, + { expiresAt: createdAt + 86_400_001 }, { signature: "00".repeat(64) }, { senderKey: "00".repeat(32) }, + { ciphertextDigest: "00".repeat(32) }, + ]) expect(() => verify(frame(JSON.stringify({ ...header, ...patch })))).toThrow(); + expect(() => verifyNotificationEnvelope(frame(), "00".repeat(32), channel, topics, createdAt)).toThrow(); + expect(() => verifyNotificationEnvelope(frame(), genesis, "00".repeat(32), topics, createdAt)).toThrow(); + expect(() => verifyNotificationEnvelope(frame(), genesis, channel, topics, createdAt - 60_001)).toThrow(); + }); + + it("bounds container bytes, headers, candidate count and nesting", () => { + expect(() => verify(new Uint8Array(MAX_FULL_FRAME_BYTES + 1))).toThrow(); + expect(() => signNotificationEnvelope(metadata, new Uint8Array(MAX_CARRIER_BYTES + 1), seed)).toThrow(); + expect(() => verify(frame(" ".repeat(MAX_HEADER_BYTES + 1)))).toThrow(); + const candidates = Array.from({ length: 33 }, (_, index) => assertion(`entry-${index}`, node(leaf(String(index)), [assertion("truapiNotification", leaf(JSON.stringify(header)))]))); + expect(() => verify(concatBytes(Uint8Array.of(0xd8, 200), node(leaf(carrier), candidates).bytes))).toThrow(); + let nested = leaf(carrier); + for (let index = 0; index < 20; index++) nested = node(leaf("subject"), [assertion("nested", nested)]); + expect(() => verify(concatBytes(Uint8Array.of(0xd8, 200), nested.bytes))).toThrow(); + }); +}); diff --git a/js/packages/truapi/src/notification-envelope.ts b/js/packages/truapi/src/notification-envelope.ts new file mode 100644 index 0000000000..8e04a9794e --- /dev/null +++ b/js/packages/truapi/src/notification-envelope.ts @@ -0,0 +1,216 @@ +import { ed25519 } from "@noble/curves/ed25519.js"; +import { bytesToNumberLE } from "@noble/curves/utils.js"; +import { sha256, sha512 } from "@noble/hashes/sha2.js"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; +import { decodeNotificationContainer, encodeNotificationContainer, MAX_CONTAINER_BYTES } from "./notification-container.js"; +export { NOTIFICATION_PREDICATE, MAX_NOTIFICATION_CANDIDATES } from "./notification-container.js"; + +export const MAX_HEADER_BYTES = 16 * 1024; +export const MAX_CARRIER_BYTES = 240 * 1024; +export const MAX_FULL_FRAME_BYTES = MAX_CONTAINER_BYTES; +export const MAX_TTL_MS = 86_400_000; +export const FUTURE_SKEW_MS = 60_000; +const encoder = new TextEncoder(); + +/** Public authenticated metadata. Hex is lowercase, without a 0x prefix. */ +export interface NotificationHeader { + v: 1; + product: string; + genesis: string; + channel: string; + topics: string[]; + eventId: string; + createdAt: number; + expiresAt: number; + ciphertextDigest: string; + senderKey: string; + signature: string; +} +export type UnsignedNotificationHeader = Omit; +export type NotificationMetadata = Omit; +/** Decoding alone does not authenticate the result. */ +export interface NotificationEnvelope { + header: NotificationHeader; + carrier: Uint8Array; +} +const fields = ["v", "product", "genesis", "channel", "topics", "eventId", "createdAt", "expiresAt", "ciphertextDigest", "senderKey", "signature"]; +const hex32 = /^[0-9a-f]{64}$/; +function isHex(value: unknown, pattern = hex32): value is string { + return typeof value === "string" && pattern.test(value); +} +function validateHeader(value: unknown): asserts value is NotificationHeader { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid notification header"); + const header = value as NotificationHeader; + if (Object.keys(header).length !== fields.length || fields.some((field) => !Object.hasOwn(header, field)) + || header.v !== 1 || typeof header.product !== "string" || !/^[a-z0-9._-]{1,128}$/.test(header.product) + || !isHex(header.genesis) || !isHex(header.channel) || !isHex(header.eventId) || !isHex(header.ciphertextDigest) || !isHex(header.senderKey) + || !isHex(header.signature, /^[0-9a-f]{128}$/) + || !Array.isArray(header.topics) || header.topics.length < 1 || header.topics.length > 4 + || header.topics.some((topic) => !isHex(topic)) || new Set(header.topics).size !== header.topics.length + || !Number.isSafeInteger(header.createdAt) || header.createdAt < 0 + || !Number.isSafeInteger(header.expiresAt) || header.expiresAt <= header.createdAt + || header.expiresAt - header.createdAt > MAX_TTL_MS) throw new Error("invalid notification header"); +} + +/** Exact domain-separated UTF-8 tuple shared with the Rust verifier. */ +export function notificationSigningBytes(header: UnsignedNotificationHeader): Uint8Array { + validateHeader({ ...header, signature: "00".repeat(64) }); + return encoder.encode(JSON.stringify([ + "truapi:notification:v1", header.v, header.product, header.genesis, header.channel, + header.topics, header.eventId, header.createdAt, header.expiresAt, header.ciphertextDigest, header.senderKey, + ])); +} + +function parseHeader(text: string): NotificationHeader { + if (encoder.encode(text).length > MAX_HEADER_BYTES) throw new Error("notification header too large"); + const value: unknown = JSON.parse(text); + // JSON.parse discards duplicate keys. Tokenize valid JSON first to retain them, + // including escaped key spellings, and reject non-integer number spellings. + const tokens = text.match(/"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null|[{}\[\],:]/g) ?? []; + const keys = new Set(); + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]!; + if (tokens[index + 1] === ":") { + const key: string = JSON.parse(token); + if (keys.has(key)) throw new Error("duplicate notification field"); + keys.add(key); + } else if (/^-?\d/.test(token) && !/^(0|[1-9]\d*)$/.test(token)) { + throw new Error("non-integer notification number"); + } + } + validateHeader(value); + return value; +} + +/** Inspect bounded standard Envelope nodes for reserved assertions. + * This parses structure but not header JSON or its proof. Malformed containers throw: + * callers must drop them, never fall back to another authentication path on errors. + * Non-Envelope bytes and valid unmarked base Envelopes return false. + */ +export function isNotificationEnvelope(frame: Uint8Array): boolean { + if (frame.length === 0 || (frame[0]! >>> 5) !== 6) return false; + return decodeNotificationContainer(frame).headers.length > 0; +} + +/** Decode exactly one candidate; does not verify proof, source or freshness. */ +export function decodeNotificationEnvelope(frame: Uint8Array): NotificationEnvelope { + const { headers, subjects } = decodeNotificationContainer(frame); + if (headers.length !== 1) throw new Error("expected exactly one notification candidate"); + return decodeCandidate(headers[0]!, subjects); +} + +function decodeCandidate(json: string, subjects: ReadonlyMap): NotificationEnvelope { + const header = parseHeader(json); + const carrier = subjects.get(header.ciphertextDigest); + if (carrier === undefined || carrier.length > MAX_CARRIER_BYTES) throw new Error("missing or oversized notification byte witness"); + return { header, carrier: carrier.slice() }; +} + +/** Encode a minimal standard carrier; supplied proof is not verified. */ +export function encodeNotificationEnvelope(header: NotificationHeader, carrier: Uint8Array): Uint8Array { + validateHeader(header); + if (carrier.length > MAX_CARRIER_BYTES) throw new Error("notification carrier too large"); + const json = JSON.stringify(header); + if (encoder.encode(json).length > MAX_HEADER_BYTES) throw new Error("notification header too large"); + const frame = encodeNotificationContainer(json, carrier); + if (frame.length > MAX_FULL_FRAME_BYTES) throw new Error("notification container too large"); + return frame; +} + +/** Sign public metadata over an opaque byte witness using a raw 32-byte seed. + * Existing standard carriers can add JSON.stringify(result) as a + * NOTIFICATION_PREDICATE assertion on any node containing the byte witness. + */ +export function signNotificationHeader(metadata: NotificationMetadata, carrier: Uint8Array, seed: Uint8Array): NotificationHeader { + if (carrier.length > MAX_CARRIER_BYTES) throw new Error("notification carrier too large"); + const unsigned: UnsignedNotificationHeader = { + ...metadata, v: 1, ciphertextDigest: bytesToHex(sha256(carrier)), senderKey: bytesToHex(ed25519.getPublicKey(seed)), + }; + const signature = bytesToHex(ed25519.sign(notificationSigningBytes(unsigned), seed)); + return { ...unsigned, signature }; +} + +/** Sign and emit a minimal standard carrier, in Node, Bun or a browser. */ +export function signNotificationEnvelope(metadata: NotificationMetadata, carrier: Uint8Array, seed: Uint8Array): Uint8Array { + return encodeNotificationEnvelope(signNotificationHeader(metadata, carrier, seed), carrier); +} + +/** Authenticate optional stored metadata against supplied opaque bytes. + * Checks strict JSON, static bounds, digest and Ed25519 proof only. + * Does NOT establish current-time eligibility, actual source, product authority, + * enrollment or sender approval. Hosts/relays must use whole-carrier verification. + */ +export function authenticateNotificationHeader(json: string, carrier: Uint8Array): NotificationHeader { + if (carrier.length > MAX_CARRIER_BYTES) throw new Error("notification carrier too large"); + const header = parseHeader(json); + if (bytesToHex(sha256(carrier)) !== header.ciphertextDigest) throw new Error("notification byte witness digest mismatch"); + verifyHeaderProof(header); + return header; +} + +/** Authenticate stored bytes without applying current-time notification eligibility. + * Enrollment, product, sender approval and replay policy remain caller responsibilities. + * Subject bytes are copied so input mutation cannot alter authenticated output. + */ +export function authenticateNotificationEnvelope(frame: Uint8Array, actualGenesis: string, actualChannel: string, actualTopics: readonly string[]): NotificationEnvelope { + return authenticateCandidate(decodeNotificationEnvelope(frame), actualGenesis, actualChannel, actualTopics); +} + +function authenticateCandidate(envelope: NotificationEnvelope, actualGenesis: string, actualChannel: string, actualTopics: readonly string[]): NotificationEnvelope { + const { header } = envelope; + if (header.genesis !== actualGenesis || header.channel !== actualChannel + || actualTopics.length < 1 || actualTopics.length > 4 || actualTopics.some((topic) => !isHex(topic)) + || new Set(actualTopics).size !== actualTopics.length + || header.topics.some((topic) => !actualTopics.includes(topic))) throw new Error("notification source mismatch"); + verifyHeaderProof(header); + return envelope; +} + +function verifyHeaderProof(header: NotificationHeader): void { + const key = hexToBytes(header.senderKey); + const signature = hexToBytes(header.signature); + const publicPoint = ed25519.Point.fromBytes(key, false); + const noncePoint = ed25519.Point.fromBytes(signature.subarray(0, 32), false); + const order = ed25519.Point.CURVE().n; + const scalar = bytesToNumberLE(signature.subarray(32)); + if (publicPoint.isSmallOrder() || noncePoint.isSmallOrder() || scalar >= order) throw new Error("invalid notification signature"); + const challenge = bytesToNumberLE(sha512.create() + .update(signature.subarray(0, 32)).update(key).update(notificationSigningBytes(header)).digest()) % order; + // Noble's verify clears the cofactor even with zip215:false. Dalek verify_strict + // requires this exact, uncofactored equation; mixed-order points are not banned. + const expectedNonce = ed25519.Point.BASE.multiplyUnsafe(scalar).subtract(publicPoint.multiplyUnsafe(challenge)); + if (!expectedNonce.equals(noncePoint)) throw new Error("invalid notification signature"); +} + +/** Authenticate a carrier and enforce current-time notification eligibility, or throw. */ +export function verifyNotificationEnvelope(frame: Uint8Array, actualGenesis: string, actualChannel: string, actualTopics: readonly string[], nowMs: number): NotificationEnvelope { + const envelope = authenticateNotificationEnvelope(frame, actualGenesis, actualChannel, actualTopics); + const { header } = envelope; + if (!Number.isSafeInteger(nowMs) || nowMs < 0 || header.createdAt > nowMs + FUTURE_SKEW_MS || header.expiresAt <= nowMs) throw new Error("notification outside validity window"); + return envelope; +} + +/** Authenticate all candidates, omitting invalid proofs/headers, without current-time checks. + * Malformed containers or structural ambiguities throw before any result is returned. + */ +export function authenticateNotificationEnvelopes(frame: Uint8Array, actualGenesis: string, actualChannel: string, actualTopics: readonly string[]): NotificationEnvelope[] { + const { headers, subjects } = decodeNotificationContainer(frame); + const authenticated: NotificationEnvelope[] = []; + for (const json of headers) { + try { + authenticated.push(authenticateCandidate(decodeCandidate(json, subjects), actualGenesis, actualChannel, actualTopics)); + } catch { + // A candidate's invalid proof cannot suppress independently valid siblings. + } + } + return authenticated; +} + +/** Authenticate up to 32 generic candidates and keep only notification-eligible ones. + * A watch must match the SIGNED header topics, not unsigned extra actual topics. + */ +export function verifyNotificationEnvelopes(frame: Uint8Array, actualGenesis: string, actualChannel: string, actualTopics: readonly string[], nowMs: number): NotificationEnvelope[] { + if (!Number.isSafeInteger(nowMs) || nowMs < 0) throw new Error("invalid notification clock"); + return authenticateNotificationEnvelopes(frame, actualGenesis, actualChannel, actualTopics).filter(({ header }) => + header.createdAt <= nowMs + FUTURE_SKEW_MS && header.expiresAt > nowMs); +} diff --git a/package-lock.json b/package-lock.json index 2e674ae19f..bcf2e30d65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "version": "0.23.0", "license": "MIT", "dependencies": { + "@noble/curves": "^2.0.1", "@noble/hashes": "^2.2.0", "neverthrow": "^8.2.0", "scale-ts": "^1.6.1" @@ -869,10 +870,25 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", "license": "MIT", "engines": { "node": ">= 20.19.0" diff --git a/rust/crates/truapi-codegen/src/platform.rs b/rust/crates/truapi-codegen/src/platform.rs index c7962d523c..cf67b985a3 100644 --- a/rust/crates/truapi-codegen/src/platform.rs +++ b/rust/crates/truapi-codegen/src/platform.rs @@ -298,6 +298,9 @@ fn collect_referenced_local_types( names: &NameContext, ) -> Result> { let mut referenced = BTreeSet::new(); + // Resident runtime results are not platform callbacks, but share the + // canonical host-side codec surface. + referenced.insert("ReceivingRegistration".to_string()); for trait_def in traits { for method in &trait_def.methods { for param in &method.params { diff --git a/rust/crates/truapi-codegen/src/platform_callbacks.rs b/rust/crates/truapi-codegen/src/platform_callbacks.rs index 9216e86cee..517551b3c5 100644 --- a/rust/crates/truapi-codegen/src/platform_callbacks.rs +++ b/rust/crates/truapi-codegen/src/platform_callbacks.rs @@ -230,6 +230,9 @@ pub fn snake_case(name: &str) -> String { pub fn collect_local_bridge_payload_types(definition: &PlatformDefinition) -> BTreeSet<&str> { let local: BTreeSet<&str> = definition.types.iter().map(|ty| ty.name.as_str()).collect(); let mut out = BTreeSet::new(); + if local.contains("ReceivingRegistration") { + out.insert("ReceivingRegistration"); + } for trait_def in &definition.traits { for method in &trait_def.methods { for param in &method.params { diff --git a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs index c1e4f6b1c6..8c7e02f157 100644 --- a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -46,6 +46,7 @@ pub fn generate_wasm_bridge( WasmPlatform, call_js_function, decode_bytes, decode_js_item, generic, get_function, get_optional_function, invoke_bool, invoke_bytes_return, invoke_js_subscription, invoke_optional_bytes_return, invoke_optional_string_return, invoke_unit, missing_callback, parse_optional_bytes_item, + absent_optional_callback, }}; /// JS-side callbacks invoked by the wasm platform bridge. Methods with @@ -81,12 +82,16 @@ pub fn generate_wasm_bridge( .unwrap(); for field in bridge_fields(&traits, &trait_names, &optional_traits) { if field.optional { + let fallback = if field.absent_is_none { + "absent_optional_callback()".to_string() + } else { + format!("missing_callback({:?})", field.raw_name) + }; writeln!( out, - " {}: get_optional_function(callbacks, \"{}\")?\n .unwrap_or_else(|| missing_callback(\"{}\")),", - field.field_name, field.raw_name, field.raw_name - ) - .unwrap(); + " {}: get_optional_function(callbacks, {:?})?\n .unwrap_or_else(|| {}),", + field.field_name, field.raw_name, fallback + ).unwrap(); } else { writeln!( out, @@ -186,6 +191,11 @@ impl<'a> BridgeCtx<'a> { matches!(ty, TypeRef::Named { name, .. } if self.local_codec_types.contains(name.as_str())) } + fn is_encoded_codec(&self, ty: &TypeRef) -> bool { + self.is_api_codec(ty) || self.is_local_codec(ty) + || matches!(ty, TypeRef::Vec(inner) if self.is_encoded_codec(inner)) + } + fn alias_primitive(&self, ty: &TypeRef) -> Option<&'a str> { let TypeRef::Named { name, .. } = ty else { return None; @@ -212,6 +222,7 @@ struct BridgeField { field_name: String, raw_name: String, optional: bool, + absent_is_none: bool, namespace: Option, } @@ -240,7 +251,11 @@ fn bridge_fields( fields.push(BridgeField { field_name: raw_callback_field_name(trait_def, method, platform_trait_names), raw_name: raw_callback_wire_name(trait_def, method, platform_trait_names), - optional, + optional: optional || method.has_default, + absent_is_none: method.has_default && matches!( + &method.return_shape.inner, + PlatformInner::Result { ok: TypeRef::Option(_), .. } + ), namespace: namespace.clone(), }); } @@ -349,7 +364,21 @@ fn emit_result_method( ), &map_err, ) - } else if ctx.is_api_codec(ok) || ctx.is_local_codec(ok) || is_scale_vector_result(ok) { + } else if let TypeRef::Option(inner) = ok + && ctx.is_encoded_codec(inner) + { + let call = bridge_call( + "invoke_optional_bytes_return", &method.name, &args, + &[format!("{:?}", format!("{raw} must resolve to Uint8Array, null or undefined"))], + ); + let inner_type = rust_type(inner, ctx)?; + formatdoc! { + r#" + let bytes = {call}.await{map_err}?; + bytes.map(|bytes| decode_bytes::<{inner_type}>(bytes, "{raw} response did not decode"){map_err}).transpose() + "# + } + } else if ctx.is_encoded_codec(ok) || is_scale_vector_result(ok) { formatdoc_decode_result(method, ok, &raw, &args, &map_err, ctx)? } else { bail!("unsupported wasm bridge result type for `{raw}`: {ok:?}"); @@ -472,7 +501,11 @@ fn rust_params(method: &PlatformMethod, ctx: &BridgeCtx<'_>) -> Result { Ok(format!( "{}: {reference}{}", param.name, - rust_type(¶m.type_ref, ctx)? + if param.borrowed && is_string(¶m.type_ref) { + "str".to_owned() + } else { + rust_type(¶m.type_ref, ctx)? + } )) }) .collect::>>() @@ -604,11 +637,19 @@ fn js_arg_expr(name: &str, ty: &TypeRef, ctx: &BridgeCtx<'_>) -> Result if is_bytes(ty) { return Ok(format!("Uint8Array::from({name}.as_slice()).into()")); } - if ctx.is_api_codec(ty) || ctx.is_local_codec(ty) { + if ctx.is_encoded_codec(ty) { return Ok(format!( "Uint8Array::from({name}.encode().as_slice()).into()" )); } + if let TypeRef::Option(inner) = ty { + if ctx.is_encoded_codec(inner) { + return Ok(format!("{name}.as_ref().map_or(JsValue::UNDEFINED, |value| Uint8Array::from(value.encode().as_slice()).into())")); + } + if is_bytes(inner) { + return Ok(format!("{name}.as_ref().map_or(JsValue::UNDEFINED, |value| Uint8Array::from(value.as_slice()).into())")); + } + } if let Some(primitive) = ctx.alias_primitive(ty) { return numeric_js_arg(name, primitive); } @@ -833,3 +874,67 @@ fn indent_body(body: &str, spaces: usize) -> String { .collect::>() .join("\n") } + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform::{PlatformParam, PlatformReturn}; + + fn context() -> BridgeCtx<'static> { + BridgeCtx { + api_types: BTreeMap::new(), + codec_types: BTreeSet::new(), + local_types: ["ReceivingAuthority"].into_iter().collect(), + local_codec_types: ["ReceivingAuthority"].into_iter().collect(), + } + } + + fn authority_type() -> TypeRef { + TypeRef::Named { name: "ReceivingAuthority".into(), args: Vec::new() } + } + + #[test] + fn optional_codec_result_propagates_callback_failure_before_decode() { + let ok = TypeRef::Option(Box::new(authority_type())); + let error = TypeRef::Named { name: "GenericError".into(), args: Vec::new() }; + let method = PlatformMethod { + name: "receiver_authority".into(), docs: None, + params: vec![PlatformParam { name: "product".into(), type_ref: TypeRef::Primitive("str".into()), borrowed: true }], + return_shape: PlatformReturn { is_async: true, inner: PlatformInner::Result { ok: ok.clone(), err: error.clone() } }, + has_default: true, + }; + let output = emit_result_method(&method, &ok, &error, &context()).unwrap(); + assert!(output.contains("product: &str"), "{output}"); + assert!(output.contains(".await.map_err(generic)?;"), "{output}"); + assert!(output.contains("bytes.map(|bytes| decode_bytes::"), "{output}"); + assert!(output.contains(".transpose()"), "{output}"); + } + + #[test] + fn optional_and_vector_codec_arguments_use_matching_scale_payloads() { + let context = context(); + let vector = TypeRef::Vec(Box::new(authority_type())); + assert_eq!(js_arg_expr("watches", &vector, &context).unwrap(), + "Uint8Array::from(watches.encode().as_slice()).into()"); + let optional = TypeRef::Option(Box::new(authority_type())); + let output = js_arg_expr("authority", &optional, &context).unwrap(); + assert!(output.contains("map_or(JsValue::UNDEFINED")); + assert!(output.contains("value.encode()")); + } + + #[test] + fn optional_default_authority_is_absent_not_successful_enrollment() { + let method = PlatformMethod { + name: "receiver_authority".into(), docs: None, params: Vec::new(), + return_shape: PlatformReturn { is_async: true, inner: PlatformInner::Result { + ok: TypeRef::Option(Box::new(authority_type())), + err: TypeRef::Named { name: "GenericError".into(), args: Vec::new() }, + } }, + has_default: true, + }; + let notifications = PlatformTrait { name: "Notifications".into(), docs: None, methods: vec![method] }; + let fields = bridge_fields(&[¬ifications], &BTreeSet::new(), &BTreeSet::new()); + assert!(fields[0].optional); + assert!(fields[0].absent_is_none); + } +} diff --git a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs index 13634ddcb9..a3915c417a 100644 --- a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -283,7 +283,20 @@ fn emit_wasm_adapter( "#, ) .unwrap(); - if !result_codecs.is_empty() { + let needs_scale = traits.iter().flat_map(|t| &t.methods).any(|method| { + let vector_codec = |ty: &TypeRef| { + let ty = match ty { TypeRef::Option(inner) => inner.as_ref(), other => other }; + matches!(ty, TypeRef::Vec(_)) + && encoded_codec_expr(ty, codec_types, local_codec_types).is_some() + }; + method.params.iter().any(|p| vector_codec(&p.type_ref)) + || match &method.return_shape.inner { + PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => vector_codec(ok), + PlatformInner::Stream(item) => vector_codec(stream_item(item)), + _ => false, + } + }); + if needs_scale || !result_codecs.is_empty() { out.push_str("import * as S from \"@parity/truapi/scale\";\n"); } emit_import_block(&mut out, false, "@parity/truapi", &imports); @@ -882,7 +895,7 @@ fn emit_raw_callbacks( local_codec_types, platform_trait_names, ); - out.push_str(&if optional { + out.push_str(&if optional || method.has_default { mark_member_optional(&member) } else { member @@ -1078,11 +1091,15 @@ fn adapter_arg( local_codec_types: &BTreeSet, ) -> String { let name = to_camel_case(¶m.name); + if let Some(codec) = encoded_codec_expr(¶m.type_ref, codec_types, local_codec_types) { + return format!("{codec}.dec({name})"); + } match ¶m.type_ref { - TypeRef::Named { name: ty, .. } - if codec_types.contains(ty) || local_codec_types.contains(ty) => + TypeRef::Option(inner) + if encoded_codec_expr(inner, codec_types, local_codec_types).is_some() => { - format!("{ty}.dec({name})") + let codec = encoded_codec_expr(inner, codec_types, local_codec_types).unwrap(); + format!("{name} == null ? undefined : {codec}.dec({name})") } _ => name, } @@ -1211,6 +1228,11 @@ fn validate_adapter_codec_boundary_type( position: &str, method_name: &str, ) -> Result<()> { + if encoded_codec_expr(ty, codec_types, local_codec_types).is_some() + || matches!(ty, TypeRef::Option(inner) if encoded_codec_expr(inner, codec_types, local_codec_types).is_some()) + { + return Ok(()); + } if contains_non_direct_codec_type(ty, codec_types, local_codec_types) { bail!( "unsupported compound codec type in host callback `{method_name}` {position}: {ty:?}" @@ -1219,9 +1241,7 @@ fn validate_adapter_codec_boundary_type( Ok(()) } -/// Named codec payload parameters must cross directly. Vector results use an -/// emitted inline SCALE codec and validate their elements separately; other -/// containers of named codecs remain unsupported. +/// Reject compound shapes for which the bridge has no shared codec lowering. fn contains_non_direct_codec_type( ty: &TypeRef, codec_types: &BTreeSet, @@ -1255,6 +1275,23 @@ fn contains_non_direct_codec_type( walk(ty, false, codec_types, local_codec_types) } +fn encoded_codec_expr( + ty: &TypeRef, + codec_types: &BTreeSet, + local_codec_types: &BTreeSet, +) -> Option { + match ty { + TypeRef::Named { name, args } + if args.is_empty() && (codec_types.contains(name) || local_codec_types.contains(name)) => + { + Some(name.clone()) + } + TypeRef::Vec(inner) => encoded_codec_expr(inner, codec_types, local_codec_types) + .map(|codec| format!("S.Vector({codec})")), + _ => None, + } +} + /// The adapter implementation expression for a unary callback: decode codec /// params, call the typed host method, SCALE-encode a codec result. fn adapter_unary_impl( @@ -1272,16 +1309,16 @@ fn adapter_unary_impl( .collect::>() .join(", "); let call = format!("{host_method}({args})"); - let body = match ok { - TypeRef::Named { name: ty, .. } - if codec_types.contains(ty) || local_codec_types.contains(ty) => - { - format!("{ty}.enc(await {call})") - } - ty if is_scale_vector_result(ty) => { - format!("{}ResultCodec.enc(await {call})", raw_callback_name(method)) - } - _ => format!("await {call}"), + let body = if is_scale_vector_result(ok) { + format!("{}ResultCodec.enc(await {call})", raw_callback_name(method)) + } else if let Some(codec) = encoded_codec_expr(ok, codec_types, local_codec_types) { + format!("{codec}.enc(await {call})") + } else if let TypeRef::Option(inner) = ok + && let Some(codec) = encoded_codec_expr(inner, codec_types, local_codec_types) + { + format!("{{ const value = await {call}; return value == null ? undefined : {codec}.enc(value); }}") + } else { + format!("await {call}") }; Ok(format!("async ({params}) => {body}")) } @@ -2009,15 +2046,40 @@ mod tests { #[test] fn wasm_adapter_rejects_unsupported_compound_codec_return_shapes() { let codec = named("HostFeatureSupportedResponse"); - assert_rejects_compound_codec(method_with_return(TypeRef::Option(Box::new(codec.clone())))); assert_rejects_compound_codec(method_with_return(TypeRef::Tuple(vec![codec]))); } #[test] fn wasm_adapter_rejects_compound_codec_param_shapes() { let codec = named("HostFeatureSupportedRequest"); - assert_rejects_compound_codec(method_with_param(TypeRef::Vec(Box::new(codec.clone())))); - assert_rejects_compound_codec(method_with_param(TypeRef::Option(Box::new(codec.clone())))); assert_rejects_compound_codec(method_with_param(TypeRef::Tuple(vec![codec]))); } + + #[test] + fn wasm_adapter_encodes_optional_and_vector_codec_results() { + for (shape, expected) in [ + (TypeRef::Vec(Box::new(named("HostFeatureSupportedResponse"))), + "featureSupportedResultCodec.enc(await callbacks.features.featureSupported("), + (TypeRef::Option(Box::new(named("HostFeatureSupportedResponse"))), + "value == null ? undefined : HostFeatureSupportedResponse.enc(value)"), + ] { + let definition = platform_with_method(method_with_return(shape)); + let output = emit_wasm_adapter(&definition, &codec_types(), &BTreeSet::new()).unwrap(); + assert!(output.contains(expected), "{output}"); + } + } + + #[test] + fn wasm_adapter_decodes_optional_and_vector_codec_parameters() { + for (shape, expected) in [ + (TypeRef::Vec(Box::new(named("HostFeatureSupportedRequest"))), + "S.Vector(HostFeatureSupportedRequest).dec(request)"), + (TypeRef::Option(Box::new(named("HostFeatureSupportedRequest"))), + "request == null ? undefined : HostFeatureSupportedRequest.dec(request)"), + ] { + let definition = platform_with_method(method_with_param(shape)); + let output = emit_wasm_adapter(&definition, &codec_types(), &BTreeSet::new()).unwrap(); + assert!(output.contains(expected), "{output}"); + } + } } diff --git a/rust/crates/truapi-codegen/tests/emission.rs b/rust/crates/truapi-codegen/tests/emission.rs new file mode 100644 index 0000000000..511fde49f6 --- /dev/null +++ b/rust/crates/truapi-codegen/tests/emission.rs @@ -0,0 +1,209 @@ +//! Determinism test for generated protocol and host bindings. +//! +//! `cargo rustdoc` runs once per package, under the nightly named in the +//! repository's `nightly-toolchain` file, into a dedicated +//! `target/codegen-test-rustdoc/` directory, off the shared +//! `target/doc/.json` path that a concurrent `cargo doc` would +//! claim. Every test reads the same JSON, so the build is paid once per +//! package per run. That toolchain is required; if it is not installed the +//! test panics rather than silently passing (`rustup toolchain install +//! "$(cat nightly-toolchain)"`). `TRUAPI_NIGHTLY_TOOLCHAIN` overrides it. + +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::{Mutex, OnceLock}; + +/// The dated nightly CI runs, unless `TRUAPI_NIGHTLY_TOOLCHAIN` names another. +fn nightly_toolchain() -> String { + std::env::var("TRUAPI_NIGHTLY_TOOLCHAIN").unwrap_or_else(|_| { + include_str!("../../../../nightly-toolchain") + .trim() + .to_string() + }) +} + + +/// Path to the rustdoc JSON of `truapi`'s protocol definitions alone, the +/// input codegen reads the API from, building it on first use. +fn produce_rustdoc_json(workspace_root: &Path) -> PathBuf { + produce_rustdoc_json_for_package( + workspace_root, + "truapi", + &["--no-default-features", "--features", "host-api"], + ) +} + +/// Path to `package`'s rustdoc JSON built with `cargo_args`, building it on +/// first use and reusing that build for every later caller in this test +/// binary. Panics with a clear message if nightly is unavailable so CI cannot +/// pass vacuously. +fn produce_rustdoc_json_for_package( + workspace_root: &Path, + package: &str, + cargo_args: &[&str], +) -> PathBuf { + static BUILT: OnceLock>> = OnceLock::new(); + let built = BUILT.get_or_init(|| Mutex::new(HashMap::new())); + // Held across the build so two tests asking for the same package queue + // instead of racing into one target directory. + let mut built = built + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let key = [package] + .into_iter() + .chain(cargo_args.iter().copied()) + .collect::>() + .join(" "); + if let Some(json) = built.get(&key) { + return json.clone(); + } + + let target_dir = workspace_root + .join("target/codegen-test-rustdoc") + .join(key.replace(' ', "_")); + let json = run_rustdoc_json(workspace_root, &target_dir, package, cargo_args); + built.insert(key, json.clone()); + json +} + +/// One `cargo rustdoc --output-format json` invocation on the pinned nightly, returning +/// the path to the JSON it wrote. +fn run_rustdoc_json( + workspace_root: &Path, + target_dir: &Path, + package: &str, + cargo_args: &[&str], +) -> PathBuf { + let toolchain = nightly_toolchain(); + let mut command = Command::new("cargo"); + command + .arg(format!("+{toolchain}")) + .args(["rustdoc", "-p", package]) + .args(cargo_args) + .arg("--target-dir") + .arg(target_dir) + .args(["--", "-Z", "unstable-options", "--output-format", "json"]) + .current_dir(workspace_root); + let output = command.output().expect( + "failed to spawn rustdoc; install the pinned nightly named in nightly-toolchain via rustup", + ); + assert!( + output.status.success(), + "`cargo +{toolchain} rustdoc -p {package}` failed (status {}); that nightly toolchain is required.\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + let json_name = package.replace('-', "_"); + let json = target_dir.join(format!("doc/{json_name}.json")); + assert!( + json.exists(), + "rustdoc JSON not found at {} after successful rustdoc invocation", + json.display(), + ); + json +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(3) + .expect("workspace root above rust/crates/truapi-codegen") + .to_path_buf() +} + +fn workspace_tempdir(workspace: &Path) -> tempfile::TempDir { + let parent = workspace.join("target/codegen-test-tmp"); + fs::create_dir_all(&parent).expect("create workspace codegen temp directory"); + tempfile::Builder::new() + .prefix("golden-") + .tempdir_in(parent) + .expect("workspace tempdir") +} + + +/// Idempotence guard at the integration level: running the binary twice +/// against the same input must produce identical output. This catches +/// non-determinism (HashMap iteration order, timestamps, etc.) that the +/// inline unit tests might miss because they exercise smaller APIs. +#[test] +fn binary_emission_is_idempotent() { + let workspace = workspace_root(); + let rustdoc_json = produce_rustdoc_json(&workspace); + let runtime_json = produce_rustdoc_json_for_package(&workspace, "truapi", &[]); + let provider_json = produce_rustdoc_json_for_package(&workspace, "truapi-provider", &[]); + + // Every emitted file, not a hand-picked list: the nondeterminism this + // guards against has landed in the TypeScript output as readily as in the + // Rust output, and a list only covers what someone remembered to add. + let run_once = || -> BTreeMap { + let tmp = workspace_tempdir(&workspace); + let status = Command::new(env!("CARGO_BIN_EXE_truapi-codegen")) + .args([ + "--input", + rustdoc_json.to_str().unwrap(), + "--output", + tmp.path().join("ts").to_str().unwrap(), + "--rust-output", + tmp.path().join("rust").to_str().unwrap(), + "--platform-input", + runtime_json.to_str().unwrap(), + "--platform-input", + provider_json.to_str().unwrap(), + "--platform-ts-output", + tmp.path().join("host").to_str().unwrap(), + "--platform-wasm-adapter-output", + tmp.path().join("wasm").to_str().unwrap(), + "--platform-rust-output", + tmp.path().join("rust-wasm").to_str().unwrap(), + ]) + .status() + .expect("run truapi-codegen"); + assert!(status.success(), "codegen run failed"); + read_tree(tmp.path()) + }; + + let first = run_once(); + let second = run_once(); + assert!(!first.is_empty(), "codegen emitted nothing"); + assert_eq!( + first.keys().collect::>(), + second.keys().collect::>(), + "the two runs emitted different files" + ); + for (path, contents) in &first { + assert_eq!( + contents, + &second[path], + "{} differs between runs", + path.display() + ); + } +} + +/// Every file under `root`, keyed by its path relative to `root`. +fn read_tree(root: &Path) -> BTreeMap { + let mut files = BTreeMap::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(dir) = pending.pop() { + for entry in fs::read_dir(&dir).expect("read generated directory") { + let path = entry.expect("read generated entry").path(); + if path.is_dir() { + pending.push(path); + } else { + let relative = path + .strip_prefix(root) + .expect("path under root") + .to_path_buf(); + files.insert( + relative, + fs::read_to_string(&path).expect("read generated file"), + ); + } + } + } + files +} + diff --git a/rust/crates/truapi/Cargo.toml b/rust/crates/truapi/Cargo.toml index ee87c2aa23..19f62656b7 100644 --- a/rust/crates/truapi/Cargo.toml +++ b/rust/crates/truapi/Cargo.toml @@ -58,6 +58,7 @@ runtime = [ "dep:blake2b_simd", "dep:sp-crypto-hashing", "dep:schnorrkel", + "dep:ed25519-dalek", "dep:substrate-bip39", "dep:getrandom", "dep:hkdf", @@ -127,6 +128,7 @@ nanoid = { workspace = true, optional = true } blake2b_simd = { workspace = true, optional = true } sp-crypto-hashing = { workspace = true, optional = true } schnorrkel = { workspace = true, features = ["alloc", "getrandom"], optional = true } +ed25519-dalek = { workspace = true, optional = true } substrate-bip39 = { workspace = true, optional = true } zeroize = { workspace = true, default-features = false, features = ["alloc", "derive"] } getrandom = { workspace = true, features = ["js"], optional = true } diff --git a/rust/crates/truapi/src/api/notifications.rs b/rust/crates/truapi/src/api/notifications.rs index b4cc040bf7..df77831d89 100644 --- a/rust/crates/truapi/src/api/notifications.rs +++ b/rust/crates/truapi/src/api/notifications.rs @@ -4,11 +4,18 @@ use crate::versioned::notifications::{ HostPushNotificationCancelError, HostPushNotificationCancelRequest, HostPushNotificationCancelResponse, HostPushNotificationError, HostPushNotificationRequest, HostPushNotificationResponse, + HostNotificationReceiverStatusRequest, HostNotificationReceiverStatusResponse, + HostNotificationReplaceReceiverRequest, HostNotificationReplaceReceiverResponse, + HostNotificationDisableReceiverRequest, HostNotificationDisableReceiverResponse, + HostNotificationRecordReceiptRequest, HostNotificationRecordReceiptResponse, + HostNotificationReceiverEventsRequest, HostNotificationReceiverEventsResponse, + HostNotificationAcknowledgeReceiverEventRequest, HostNotificationAcknowledgeReceiverEventResponse, + HostNotificationReceivingError, }; use crate::{CallContext, CallError}; use crate::{wire, wire_trait}; -/// Notification methods for locally-rendered push notifications. +/// Local notification scheduling and consent-scoped background receiving. #[wire_trait(id = 8)] #[crate::async_trait] pub trait Notifications: Send + Sync { @@ -56,4 +63,111 @@ pub trait Notifications: Send + Sync { cx: &CallContext, request: HostPushNotificationCancelRequest, ) -> Result>; + + /// Inspect current host support, consent and durable registration state. + /// + /// ```ts + /// const result = await truapi.notifications.receiverStatus(); + /// assert(result.isOk(), "receiverStatus failed:", result); + /// console.log(result.value); + /// ``` + #[wire(id = 2)] + async fn receiver_status( + &self, + cx: &CallContext, + request: HostNotificationReceiverStatusRequest, + ) -> Result>; + + /// Atomically replace watches under explicit receiving consent. + /// + /// ```ts + /// const status = await truapi.notifications.receiverStatus(); + /// assert(status.isOk(), "receiverStatus failed:", status); + /// // An empty policy removes every watch; nonempty policies require scoped consent. + /// const result = await truapi.notifications.replaceReceiver({ + /// expectedRevision: status.value.revision, + /// watches: [], + /// }); + /// assert(result.isOk(), "replaceReceiver failed:", result); + /// ``` + #[wire(id = 3)] + async fn replace_receiver( + &self, + cx: &CallContext, + request: HostNotificationReplaceReceiverRequest, + ) -> Result>; + + /// Disable locally and queue transport revocation without waiting for it. + /// + /// ```ts + /// const status = await truapi.notifications.receiverStatus(); + /// assert(status.isOk(), "receiverStatus failed:", status); + /// const result = await truapi.notifications.disableReceiver({ + /// expectedRevision: status.value.revision, + /// }); + /// assert(result.isOk(), "disableReceiver failed:", result); + /// ``` + #[wire(id = 4)] + async fn disable_receiver( + &self, + cx: &CallContext, + request: HostNotificationDisableReceiverRequest, + ) -> Result>; + + /// Record foreground handling, reading or actual OS display, and return the + /// confirmed/pending display outcome. A reservation is not proof of display. + /// + /// ```ts + /// const events = await truapi.notifications.receiverEvents({ afterSequence: 0n }); + /// assert(events.isOk(), "receiverEvents failed:", events); + /// const event = events.value[0]; + /// if (event) { + /// const result = await truapi.notifications.recordReceipt({ + /// revision: event.revision, watchId: event.watchId, + /// eventId: event.eventId, kind: "Foreground", + /// }); + /// assert(result.isOk(), "recordReceipt failed:", result); + /// console.log(result.value); + /// } + /// ``` + #[wire(id = 5)] + async fn record_receipt( + &self, + cx: &CallContext, + request: HostNotificationRecordReceiptRequest, + ) -> Result>; + + /// Poll bounded durable delivery and activation events. + /// + /// ```ts + /// const result = await truapi.notifications.receiverEvents({ afterSequence: 0n }); + /// assert(result.isOk(), "receiverEvents failed:", result); + /// console.log(result.value); + /// ``` + #[wire(id = 6)] + async fn receiver_events( + &self, + cx: &CallContext, + request: HostNotificationReceiverEventsRequest, + ) -> Result>; + + /// Acknowledge an event after application handling. + /// + /// ```ts + /// const events = await truapi.notifications.receiverEvents({ afterSequence: 0n }); + /// assert(events.isOk(), "receiverEvents failed:", events); + /// for (const event of events.value) { + /// console.log("Received event:", event.kind, event.watchId); + /// const result = await truapi.notifications.acknowledgeReceiverEvent({ + /// sequence: event.sequence, + /// }); + /// assert(result.isOk(), "acknowledgeReceiverEvent failed:", result); + /// } + /// ``` + #[wire(id = 7)] + async fn acknowledge_receiver_event( + &self, + cx: &CallContext, + request: HostNotificationAcknowledgeReceiverEventRequest, + ) -> Result>; } diff --git a/rust/crates/truapi/src/host_core.rs b/rust/crates/truapi/src/host_core.rs index 057ee9eaac..41b3033003 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -187,6 +187,11 @@ pub struct PairingHostRuntime { } impl PairingHostRuntime { + /// Host-only receiving engine, independent of product execution lifetime. + pub fn receiving(&self) -> &Arc { + &self.services.receiving + } + /// Build a long-lived pairing-host runtime around a platform implementation. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.new"))] pub fn new

(platform: Arc

, config: PairingHostConfig, spawner: Spawner) -> Self @@ -353,12 +358,22 @@ impl PairingHostRuntime { /// /// The next product login request generates a fresh pairing identity and /// presents a new deeplink suitable for another signing host. + /// Local receiving revocation is attempted first; even if persistence fails, + /// the captured session is closed and a warning is returned. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.logout"))] pub async fn logout(&self) -> Result<(), v01::GenericError> { - self.pairing_host - .logout_and_reset_pairing() - .await - .map_err(|reason| v01::GenericError { reason }) + let revocation = self.services.receiving.revoke_all().await; + let logout = self.pairing_host.logout_and_reset_pairing().await; + if revocation.is_err() { + return Err(v01::GenericError { + reason: if logout.is_ok() { + "logged out, but background receiving could not be durably revoked and may remain enabled" + } else { + "background receiving could not be durably revoked and may remain enabled; logout reset also failed" + }.to_string(), + }); + } + logout.map_err(|reason| v01::GenericError { reason }) } /// Clear one product's capability state while preserving the active @@ -596,6 +611,11 @@ pub struct SigningHostRuntime { } impl SigningHostRuntime { + /// Host-only receiving engine, independent of product execution lifetime. + pub fn receiving(&self) -> &Arc { + &self.services.receiving + } + /// Answer resource allocation as granted without performing it. /// /// For test hosts only, with the `test-host` feature enabled. diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index a602744494..45d95b3d37 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -118,6 +118,12 @@ pub mod latest { Shape, SignedStatement, Size, Statement, StatementProof, StorageQueryItem, StorageQueryType, StorageResultItem, TextFieldProps, TextProps, ThemeName, ThemeVariant, TxPayloadExtension, TypographyStyle, VerticalAlignment, VrfSignature, + HostNotificationReceiverStatus, HostNotificationReceivingError, + HostNotificationReceiptResult, + HostNotificationReplaceReceiverRequest, HostNotificationDisableReceiverRequest, + HostNotificationRecordReceiptRequest, HostNotificationReceiverEventsRequest, + HostNotificationAcknowledgeReceiverEventRequest, ReceivingWatch, ReceivingEvent, + ReceivingEventKind, ReceivingReceiptKind, }; pub use crate::v02::{ HostNativeChatAcknowledgment, HostNativeChatAttachment, HostNativeChatAttachmentKind, diff --git a/rust/crates/truapi/src/native/callbacks.rs b/rust/crates/truapi/src/native/callbacks.rs index 46c3f01bda..3163c2da43 100644 --- a/rust/crates/truapi/src/native/callbacks.rs +++ b/rust/crates/truapi/src/native/callbacks.rs @@ -72,6 +72,30 @@ pub trait HostCallbacks: Send + Sync { /// Cancel a notification by id. fn cancel_notification(&self, id: u32) -> Result<(), HostRejection>; + /// Runtime callbacks return current host scope, including while products are closed. + /// Per-execution callbacks return the immutable verified artifact/account scope + /// captured at execution creation, never the latest replacement scope. + async fn receiver_authority( + &self, + product_id: String, + ) -> Result, HostRejection>; + + /// Request consent distinct from OS notification permission. + async fn receiver_consent( + &self, + authority: crate::platform::ReceivingAuthority, + watches: Vec, + ) -> Result; + + /// Wake asynchronous transport synchronization without awaiting network I/O. + async fn receiver_changed(&self) -> Result<(), HostRejection>; + + /// Optionally forward a trusted product receiving command to its sole owner. + /// `None` uses the native resident engine; failure must not select another owner. + async fn receiver_command( + &self, product_id: String, action: u8, payload: Vec, + ) -> Result>, HostRejection>; + /// Prompt the user for a device-level permission (camera, mic, ...) /// `product` requested; the host preserves whether approval applies once /// or always. diff --git a/rust/crates/truapi/src/native/errors.rs b/rust/crates/truapi/src/native/errors.rs index bef4496253..3599f2cc3a 100644 --- a/rust/crates/truapi/src/native/errors.rs +++ b/rust/crates/truapi/src/native/errors.rs @@ -72,6 +72,12 @@ impl From for HostRejection { } } +impl From for HostRejection { + fn from(error: crate::latest::HostNotificationReceivingError) -> Self { + Self::Rejected { reason: format!("background receiving: {error:?}") } + } +} + /// Why the core database status could not be read. #[derive(Debug, Clone, thiserror::Error, uniffi::Error)] pub enum NativeCoreDatabaseError { diff --git a/rust/crates/truapi/src/native/platform.rs b/rust/crates/truapi/src/native/platform.rs index 4c3b3fb9a8..22da643e2b 100644 --- a/rust/crates/truapi/src/native/platform.rs +++ b/rust/crates/truapi/src/native/platform.rs @@ -271,6 +271,33 @@ impl Notifications for CallbackPlatform { .cancel_notification(id) .map_err(v01::GenericError::from) } + + async fn receiver_authority( + &self, + product_id: &str, + ) -> Result, v01::GenericError> { + self.callbacks.receiver_authority(product_id.to_owned()).await + .map_err(v01::GenericError::from) + } + + async fn receiver_consent( + &self, + authority: crate::platform::ReceivingAuthority, + watches: Vec, + ) -> Result { + self.callbacks.receiver_consent(authority, watches).await + .map_err(v01::GenericError::from) + } + + async fn receiver_changed(&self) -> Result<(), v01::GenericError> { + self.callbacks.receiver_changed().await.map_err(v01::GenericError::from) + } + + async fn receiver_command( + &self, product_id: String, action: u8, payload: Vec, + ) -> Result>, v01::GenericError> { + self.callbacks.receiver_command(product_id, action, payload).await.map_err(v01::GenericError::from) + } } #[async_trait] diff --git a/rust/crates/truapi/src/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs index c6eaa3416c..93a83215c2 100644 --- a/rust/crates/truapi/src/native/runtime.rs +++ b/rust/crates/truapi/src/native/runtime.rs @@ -266,6 +266,79 @@ impl NativeTrUApiHostRuntime { ) } + /// Enumerate durable receiving registrations, including synchronized ones. + pub async fn receiving_pending(&self) -> Result, HostRejection> { + self.runtime.receiving().pending().await.map_err(HostRejection::from) + } + + /// Acknowledge exactly the durable revision synchronized by the transport. + pub async fn receiving_synchronized(&self, product_id: String, revision: u64) -> Result { + self.runtime.receiving().synchronized(&product_id, revision).await.map_err(HostRejection::from) + } + + /// Verify a complete frame against its independently observed chain and topics. + pub async fn receiving_ingest( + &self, product_id: String, revision: u64, watch_id: String, + actual_genesis: String, actual_channel: String, actual_topics: Vec, frame: Vec, + ) -> Result, HostRejection> { + self.runtime.receiving().ingest(&product_id, revision, watch_id, actual_genesis, actual_channel, actual_topics, frame) + .await.map_err(HostRejection::from) + } + + /// Decode and authenticate a raw SCALE statement before receiving its frame. + pub async fn receiving_ingest_statement( + &self, product_id: String, revision: u64, watch_id: String, + actual_genesis: String, statement: Vec, + ) -> Result, HostRejection> { + self.runtime.receiving().ingest_statement(&product_id, revision, watch_id, actual_genesis, statement) + .await.map_err(HostRejection::from) + } + + /// Reserve display after foreground grace, rechecking receipts and authority. + pub async fn receiving_prepare_display( + &self, product_id: String, revision: u64, event_id: String, + ) -> Result, HostRejection> { + self.runtime.receiving().prepare_display(&product_id, revision, event_id).await.map_err(HostRejection::from) + } + + /// Validate a click before loading the verified product, without enqueueing it. + pub async fn receiving_validate_activation( + &self, product_id: String, revision: u64, event_id: String, + ) -> Result, HostRejection> { + self.runtime.receiving().validate_activation(&product_id, revision, event_id).await.map_err(HostRejection::from) + } + + /// Clear a reservation only after explicit display failure, not an unknown outcome. + pub async fn receiving_cancel_display( + &self, product_id: String, revision: u64, event_id: String, + ) -> Result<(), HostRejection> { + self.runtime.receiving().cancel_display(&product_id, revision, event_id).await.map_err(HostRejection::from) + } + + /// Record actual platform display, not enrollment or ingestion. + pub async fn receiving_confirm_display( + &self, product_id: String, revision: u64, event_id: String, + ) -> Result<(), HostRejection> { + self.runtime.receiving().confirm_display(&product_id, revision, event_id).await.map_err(HostRejection::from) + } + + /// Resolve a click only under current trusted authority, without launching URLs. + pub async fn receiving_activate( + &self, product_id: String, revision: u64, event_id: String, + ) -> Result, HostRejection> { + self.runtime.receiving().activate(&product_id, revision, event_id).await.map_err(HostRejection::from) + } + + /// Revoke locally before logout or destructive account erasure. + pub async fn receiving_revoke(&self, product_id: String) -> Result<(), HostRejection> { + self.runtime.receiving().revoke(&product_id).await.map_err(HostRejection::from) + } + + /// Queue synchronization after the host durably rotates its selected transport. + pub async fn receiving_mark_transport_changed(&self, product_id: String) -> Result<(), HostRejection> { + self.runtime.receiving().mark_transport_changed(&product_id).await.map_err(HostRejection::from) + } + /// Install the host's contacts adapter, which owns the contact list and /// draws the picker. /// diff --git a/rust/crates/truapi/src/native/tests.rs b/rust/crates/truapi/src/native/tests.rs index f874f00c27..471bb712bd 100644 --- a/rust/crates/truapi/src/native/tests.rs +++ b/rust/crates/truapi/src/native/tests.rs @@ -282,6 +282,19 @@ impl HostCallbacks for EventCallbacks { async fn identity_username_candidates(&self, _: String, _: Vec) -> Result>, HostRejection> { Err(HostRejection::Rejected { reason: "no identity provider in fixture".into() }) } async fn allowed_hop_endpoints(&self, _: Vec) -> Result, HostRejection> { Ok(Vec::new()) } fn hop_connect(&self, _: Vec, _: String) -> Result, HostRejection> { Ok(None) } + async fn receiver_authority(&self, _: String) -> Result, HostRejection> { + Ok(None) + } + async fn receiver_consent(&self, _: crate::platform::ReceivingAuthority, _: Vec) -> Result { + Err(HostRejection::Rejected { reason: "background receiving unsupported".into() }) + } + async fn receiver_changed(&self) -> Result<(), HostRejection> { + Err(HostRejection::Rejected { reason: "background receiving unsupported".into() }) + } + async fn receiver_command(&self, _: String, _: u8, _: Vec) -> Result>, HostRejection> { + Ok(None) + } + fn on_core_log(&self, marker: String, _detail: String) { self.logs.lock().expect("logs mutex poisoned").push(marker); } @@ -1905,6 +1918,19 @@ fn start_ws_bridge_twice_returns_already_running() { async fn identity_username_candidates(&self, _: String, _: Vec) -> Result>, HostRejection> { Err(HostRejection::Rejected { reason: "no identity provider in fixture".into() }) } async fn allowed_hop_endpoints(&self, _: Vec) -> Result, HostRejection> { Ok(Vec::new()) } fn hop_connect(&self, _: Vec, _: String) -> Result, HostRejection> { Ok(None) } + async fn receiver_authority(&self, _: String) -> Result, HostRejection> { + Ok(None) + } + async fn receiver_consent(&self, _: crate::platform::ReceivingAuthority, _: Vec) -> Result { + Err(HostRejection::Rejected { reason: "background receiving unsupported".into() }) + } + async fn receiver_changed(&self) -> Result<(), HostRejection> { + Err(HostRejection::Rejected { reason: "background receiving unsupported".into() }) + } + async fn receiver_command(&self, _: String, _: u8, _: Vec) -> Result>, HostRejection> { + Ok(None) + } + async fn confirm_permission( &self, _review: UserConfirmationReview, @@ -2097,6 +2123,19 @@ fn pending_permission_decision_does_not_stall_bridge() { async fn identity_username_candidates(&self, _: String, _: Vec) -> Result>, HostRejection> { Err(HostRejection::Rejected { reason: "no identity provider in fixture".into() }) } async fn allowed_hop_endpoints(&self, _: Vec) -> Result, HostRejection> { Ok(Vec::new()) } fn hop_connect(&self, _: Vec, _: String) -> Result, HostRejection> { Ok(None) } + async fn receiver_authority(&self, _: String) -> Result, HostRejection> { + Ok(None) + } + async fn receiver_consent(&self, _: crate::platform::ReceivingAuthority, _: Vec) -> Result { + Err(HostRejection::Rejected { reason: "background receiving unsupported".into() }) + } + async fn receiver_changed(&self) -> Result<(), HostRejection> { + Err(HostRejection::Rejected { reason: "background receiving unsupported".into() }) + } + async fn receiver_command(&self, _: String, _: u8, _: Vec) -> Result>, HostRejection> { + Ok(None) + } + async fn confirm_permission( &self, _review: UserConfirmationReview, diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs index a63ca1d9bf..3df9ecc913 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -1143,6 +1143,46 @@ pub trait Navigation: Send + Sync { async fn navigate_to(&self, url: String) -> Result<(), HostNavigateToError>; } +/// Trusted host receiving scope, derived from verified artifact and account state. +/// It must remain available after the product execution closes. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, serde::Serialize, serde::Deserialize)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Record))] +pub struct ReceivingAuthority { + /// Verified product identifier. + pub product_id: String, + /// Opaque stable account identity, encoded as 32-byte lowercase hex. + pub account: String, + /// Host-selected network environment. + pub environment: String, + /// Verified artifact digest, encoded as 32-byte lowercase hex. + pub artifact: String, + /// Host-selected receiving chain genesis, encoded as 32-byte lowercase hex. + pub genesis: String, + /// Host logout, account and artifact fence. + pub generation: u64, + /// Current OS notification permission. + pub os_permission: bool, + /// Whether the explicitly selected transport is ready. + pub transport_ready: bool, +} + +/// Host-only durable registration awaiting transport synchronization. +/// Routes and authority identifiers stay local, not in provider payloads. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, serde::Serialize, serde::Deserialize)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Record))] +pub struct ReceivingRegistration { + /// Trusted scope under which consent was recorded. + pub authority: ReceivingAuthority, + /// Durable local revision, distinct from provider token revisions. + pub revision: u64, + /// Whether this revision enrolls watches or revokes them. + pub enabled: bool, + /// Locally approved source filters and activation routes. + pub watches: Vec, + /// Whether this revision still needs synchronization. + pub sync_pending: bool, +} + /// Deliver push notifications. #[async_trait] pub trait Notifications: Send + Sync { @@ -1159,6 +1199,50 @@ pub trait Notifications: Send + Sync { let _ = id; Ok(()) } + + /// Resolve trusted authority without prompting or consulting transport. + /// The resident platform returns current host scope, even with product UI closed. + /// A product execution's platform returns the immutable scope captured when + /// that verified execution opened, never a replacement artifact/account's scope. + /// Returning `None` explicitly advertises unsupported receiving. + async fn receiver_authority( + &self, + product_id: &str, + ) -> Result, GenericError> { + let _ = product_id; + Ok(None) + } + + /// Request distinct consent for this authority and full watch scope. + async fn receiver_consent( + &self, + authority: ReceivingAuthority, + watches: Vec, + ) -> Result { + let _ = (authority, watches); + Err(GenericError { reason: "background receiving unsupported".into() }) + } + + /// Wake the host's asynchronous synchronization loop, never await network I/O. + async fn receiver_changed(&self) -> Result<(), GenericError> { + Err(GenericError { reason: "background receiving unsupported".into() }) + } + + /// Forward receiving actions to the single host-owned receiver, if external. + /// The host must bind `product_id` to the trusted execution, not page input. + /// Payloads are latest SCALE requests for actions 2..7; the response encodes + /// `Result` without a version tag. + /// `None` selects this runtime's resident engine. An unavailable external + /// owner must return an error, never `None`, to avoid a second persistent writer. + async fn receiver_command( + &self, + product_id: String, + action: u8, + payload: Vec, + ) -> Result>, GenericError> { + let _ = (product_id, action, payload); + Ok(None) + } } /// User decision including how long an authorization should last. @@ -1964,6 +2048,10 @@ pub enum CoreStorageKey { /// Host-selected Chat network. genesis_hash: [u8; 32], }, + /// Versioned bounded receiving ledger, shared across product executions. + /// Host product/account deletion must invoke ReceivingService::revoke first. + #[codec(index = 20)] + NotificationReceiving, } /// Stable metadata describing one strictly decoded [`CoreStorageKey`]. @@ -2030,6 +2118,7 @@ pub fn describe_core_storage_key( CoreStorageKey::NativeChatFileChunk { product_id, .. } => { ("NativeChatFileChunk", Some(product_id)) } + CoreStorageKey::NotificationReceiving => ("NotificationReceiving", None), }; Ok(CoreStorageKeyDescription { kind, product_id }) } diff --git a/rust/crates/truapi/src/platform/mock.rs b/rust/crates/truapi/src/platform/mock.rs index bb0a14d050..17f7799e2c 100644 --- a/rust/crates/truapi/src/platform/mock.rs +++ b/rust/crates/truapi/src/platform/mock.rs @@ -871,6 +871,7 @@ fn core_key(key: &CoreStorageKey) -> String { CoreStorageKey::ProductManifest { product_id } => { format!("core:product-manifest:{product_id}") } + CoreStorageKey::NotificationReceiving => "core:notification-receiving".to_string(), CoreStorageKey::AllowanceKeys { session_id } => { format!("core:allowance-keys:{session_id}") } diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index 5952e2d318..beebf6ffac 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -30,6 +30,10 @@ mod pairing_host; pub mod product_manifest; mod product_subtree; mod profile; +/// Durable, host-owned notification registration and activation policy. +pub mod receiving; +/// Transport-independent authenticated notification frames. +pub mod notification_envelope; mod renderer; mod ring_vrf_registry; /// Role-neutral runtime services shared by product-facing runtimes. diff --git a/rust/crates/truapi/src/runtime/capabilities/platform.rs b/rust/crates/truapi/src/runtime/capabilities/platform.rs index 40b19e3e8e..1764299c51 100644 --- a/rust/crates/truapi/src/runtime/capabilities/platform.rs +++ b/rust/crates/truapi/src/runtime/capabilities/platform.rs @@ -2,6 +2,7 @@ use crate::platform::PermissionAuthorizationStatus; use futures::StreamExt; +use parity_scale_codec::{Decode, Encode}; use tracing::{instrument, warn}; use truapi::api::{LocalStorage, Locale, Notifications, Permissions, System, Theme, Worker}; use truapi::versioned::IntoLatest; @@ -20,6 +21,13 @@ use truapi::versioned::notifications::{ HostPushNotificationCancelError, HostPushNotificationCancelRequest, HostPushNotificationCancelResponse, HostPushNotificationError, HostPushNotificationRequest, HostPushNotificationResponse, + HostNotificationReceiverStatusRequest, HostNotificationReceiverStatusResponse, + HostNotificationReplaceReceiverRequest, HostNotificationReplaceReceiverResponse, + HostNotificationDisableReceiverRequest, HostNotificationDisableReceiverResponse, + HostNotificationRecordReceiptRequest, HostNotificationRecordReceiptResponse, + HostNotificationReceiverEventsRequest, HostNotificationReceiverEventsResponse, + HostNotificationAcknowledgeReceiverEventRequest, HostNotificationAcknowledgeReceiverEventResponse, + HostNotificationReceivingError, }; use truapi::versioned::permissions::{ HostDevicePermissionError, HostDevicePermissionRequest, HostDevicePermissionResponse, @@ -442,8 +450,43 @@ impl Locale for ProductRuntimeHost { } } -// `Notifications` delegates to the platform so hosts can own scheduling and -// cancellation while the core preserves the typed TrUAPI wire shape. +// Scheduling belongs to the platform; receiving belongs to its sole resident +// engine or explicitly forwarded external owner, never to a product lifetime. + +impl ProductRuntimeHost { + async fn forwarded_receiving( + &self, action: u8, payload: Vec, + ) -> Result, CallError> { + let Some(bytes) = self.platform.receiver_command( + self.product.product_id.clone(), action, payload, + ).await.map_err(|error| CallError::HostFailure { reason: error.reason })? else { + return Ok(None); + }; + let mut input = bytes.as_slice(); + let result = Result::::decode(&mut input) + .map_err(|_| CallError::HostFailure { reason: "invalid receiving owner response".into() })?; + if !input.is_empty() { + return Err(CallError::HostFailure { reason: "trailing receiving owner response bytes".into() }); + } + result.map(Some).map_err(|error| CallError::Domain(HostNotificationReceivingError::V1(error))) + } + + async fn receiving_execution_authority( + &self, + ) -> Result> { + let authority = self.platform.receiver_authority(&self.product.product_id).await + .map_err(|error| CallError::HostFailure { reason: error.reason })? + .ok_or_else(|| CallError::Domain(HostNotificationReceivingError::V1( + crate::latest::HostNotificationReceivingError::Unsupported, + )))?; + if authority.product_id != self.product.product_id { + return Err(CallError::Domain(HostNotificationReceivingError::V1( + crate::latest::HostNotificationReceivingError::PermissionDenied, + ))); + } + Ok(authority) + } +} #[truapi::async_trait] impl Notifications for ProductRuntimeHost { @@ -505,4 +548,126 @@ impl Notifications for ProductRuntimeHost { })) }) } + + async fn receiver_status( + &self, + _cx: &CallContext, + _request: HostNotificationReceiverStatusRequest, + ) -> Result> { + if let Some(status) = self.forwarded_receiving(2, Vec::new()).await? { + return Ok(HostNotificationReceiverStatusResponse::V1(status)); + } + let authority = match self.receiving_execution_authority().await { + Ok(authority) => authority, + Err(CallError::Domain(HostNotificationReceivingError::V1( + crate::latest::HostNotificationReceivingError::Unsupported, + ))) => return Ok(HostNotificationReceiverStatusResponse::V1( + crate::latest::HostNotificationReceiverStatus { + supported: false, os_permission: false, consent: false, enabled: false, + revision: 0, sync_pending: false, transport_ready: false, + }, + )), + Err(error) => return Err(error), + }; + self.services.receiving.for_execution(authority).status().await + .map(HostNotificationReceiverStatusResponse::V1) + .map_err(|error| CallError::Domain(HostNotificationReceivingError::V1(error))) + } + + async fn replace_receiver( + &self, + cx: &CallContext, + request: HostNotificationReplaceReceiverRequest, + ) -> Result> { + let HostNotificationReplaceReceiverRequest::V1(request) = request; + let status = self.permissions_service() + .authorize_device(v01::HostDevicePermissionRequest::Notifications).await + .map_err(|error| CallError::Domain(HostNotificationReceivingError::V1( + crate::latest::HostNotificationReceivingError::Storage { + reason: format!("permission storage failed: {error:?}"), + }, + )))?; + if status != PermissionAuthorizationStatus::Authorized { + return Err(CallError::Domain(HostNotificationReceivingError::V1( + crate::latest::HostNotificationReceivingError::PermissionDenied, + ))); + } + if let Some(reason) = cx.cancel().reason() { + return Err(CallError::Domain(HostNotificationReceivingError::V1( + crate::latest::HostNotificationReceivingError::InvalidRequest { + reason: format!("receiving enrollment {reason}"), + }, + ))); + } + if let Some(status) = self.forwarded_receiving(3, request.encode()).await? { + return Ok(HostNotificationReplaceReceiverResponse::V1(status)); + } + let authority = self.receiving_execution_authority().await?; + self.services.receiving.for_execution(authority) + .replace(request.expected_revision, request.watches).await + .map(HostNotificationReplaceReceiverResponse::V1) + .map_err(|error| CallError::Domain(HostNotificationReceivingError::V1(error))) + } + + async fn disable_receiver( + &self, + _cx: &CallContext, + request: HostNotificationDisableReceiverRequest, + ) -> Result> { + let HostNotificationDisableReceiverRequest::V1(request) = request; + if let Some(status) = self.forwarded_receiving(4, request.encode()).await? { + return Ok(HostNotificationDisableReceiverResponse::V1(status)); + } + let authority = self.receiving_execution_authority().await?; + self.services.receiving.for_execution(authority).disable(request.expected_revision).await + .map(HostNotificationDisableReceiverResponse::V1) + .map_err(|error| CallError::Domain(HostNotificationReceivingError::V1(error))) + } + + async fn record_receipt( + &self, + _cx: &CallContext, + request: HostNotificationRecordReceiptRequest, + ) -> Result> { + let HostNotificationRecordReceiptRequest::V1(request) = request; + if let Some(outcome) = self.forwarded_receiving(5, request.encode()).await? { + return Ok(HostNotificationRecordReceiptResponse::V1(outcome)); + } + let authority = self.receiving_execution_authority().await?; + self.services.receiving.for_execution(authority).receipt( + request.revision, request.watch_id, request.event_id, request.kind, + ).await + .map(HostNotificationRecordReceiptResponse::V1) + .map_err(|error| CallError::Domain(HostNotificationReceivingError::V1(error))) + } + + async fn receiver_events( + &self, + _cx: &CallContext, + request: HostNotificationReceiverEventsRequest, + ) -> Result> { + let HostNotificationReceiverEventsRequest::V1(request) = request; + if let Some(events) = self.forwarded_receiving(6, request.encode()).await? { + return Ok(HostNotificationReceiverEventsResponse::V1(events)); + } + let authority = self.receiving_execution_authority().await?; + self.services.receiving.for_execution(authority).events(request.after_sequence).await + .map(HostNotificationReceiverEventsResponse::V1) + .map_err(|error| CallError::Domain(HostNotificationReceivingError::V1(error))) + } + + async fn acknowledge_receiver_event( + &self, + _cx: &CallContext, + request: HostNotificationAcknowledgeReceiverEventRequest, + ) -> Result> { + let HostNotificationAcknowledgeReceiverEventRequest::V1(request) = request; + if let Some(()) = self.forwarded_receiving(7, request.encode()).await? { + return Ok(HostNotificationAcknowledgeReceiverEventResponse::V1); + } + let authority = self.receiving_execution_authority().await?; + self.services.receiving.for_execution(authority).acknowledge(request.sequence).await + .map(|()| HostNotificationAcknowledgeReceiverEventResponse::V1) + .map_err(|error| CallError::Domain(HostNotificationReceivingError::V1(error))) + } } diff --git a/rust/crates/truapi/src/runtime/notification_envelope.rs b/rust/crates/truapi/src/runtime/notification_envelope.rs new file mode 100644 index 0000000000..146bd231fe --- /dev/null +++ b/rust/crates/truapi/src/runtime/notification_envelope.rs @@ -0,0 +1,520 @@ +//! Generic authenticated notifications in a bounded standard Gordian Envelope. +//! The container grammar follows draft-mcnally-envelope-12, section 3; it does +//! not interpret application assertions. This profile permits integer-only dCBOR +//! leaves, at most 128 assertions per node, 4096 CBOR items and depth 16. + +use std::collections::BTreeMap; +use ed25519_dalek::{Signature, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use unicode_normalization::UnicodeNormalization; + +/// Maximum UTF-8 JSON header length. +pub const MAX_HEADER_BYTES: usize = 16 * 1024; +/// Maximum opaque byte-leaf witness length. +pub const MAX_CARRIER_BYTES: usize = 240 * 1024; +/// Total container bound, including unrelated application assertions. +pub const MAX_FULL_FRAME_BYTES: usize = 256 * 1024; +/// Maximum signed candidates anywhere in a container. +pub const MAX_NOTIFICATION_CANDIDATES: usize = 32; +/// Maximum signed lifetime in milliseconds. +pub const MAX_TTL_MS: u64 = 86_400_000; +/// Maximum creation time ahead of the local clock. +pub const FUTURE_SKEW_MS: u64 = 60_000; +const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; +const PREDICATE: &str = "truapiNotification"; + +/// Strict version-one metadata. Hex fields are lowercase without a prefix. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct NotificationHeader { + /// Exactly one. + pub v: u64, + /// ASCII product label matching `[a-z0-9._-]{1,128}`. + pub product: String, + /// Actual chain genesis hash. + pub genesis: String, + /// Actual source channel. + pub channel: String, + /// Ordered, distinct authenticated topic subset, between one and four. + pub topics: Vec, + /// Application-generated event identifier. + pub event_id: String, + /// Creation time in epoch milliseconds. + pub created_at: u64, + /// Exclusive expiry time in epoch milliseconds. + pub expires_at: u64, + /// SHA-256 of a byte-leaf witness in the carrier, not application assertions. + pub ciphertext_digest: String, + /// Raw Ed25519 public key. + pub sender_key: String, + /// Raw Ed25519 signature of the domain-separated tuple. + pub signature: String, +} + +/// Verified metadata with proven byte membership, not an enrollment or replay decision. +#[derive(Debug)] +pub struct VerifiedNotification { + /// Authenticated public metadata. + pub header: NotificationHeader, +} + +fn canonical_hex(value: &str, bytes: usize) -> bool { + value.len() == bytes * 2 + && value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn validate_header(header: &NotificationHeader) -> Result<(), String> { + if header.v != 1 + || header.product.is_empty() + || header.product.len() > 128 + || !header.product.bytes().all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte)) + || !canonical_hex(&header.genesis, 32) + || !canonical_hex(&header.channel, 32) + || !canonical_hex(&header.event_id, 32) + || !canonical_hex(&header.ciphertext_digest, 32) + || !canonical_hex(&header.sender_key, 32) + || !canonical_hex(&header.signature, 64) + || !(1..=4).contains(&header.topics.len()) + || header.topics.iter().enumerate().any(|(index, topic)| !canonical_hex(topic, 32) || header.topics[..index].contains(topic)) + || header.created_at > MAX_SAFE_INTEGER + || header.expires_at > MAX_SAFE_INTEGER + || header.expires_at <= header.created_at + || header.expires_at - header.created_at > MAX_TTL_MS + { + return Err("invalid notification header".into()); + } + Ok(()) +} + +fn signing_bytes(header: &NotificationHeader) -> Result, String> { + serde_json::to_vec(&( + "truapi:notification:v1", header.v, &header.product, &header.genesis, + &header.channel, &header.topics, &header.event_id, header.created_at, + header.expires_at, &header.ciphertext_digest, &header.sender_key, + )).map_err(|error| error.to_string()) +} + +#[derive(Clone, Copy, PartialEq)] +enum Kind { Leaf, Node, Assertion, Elided, Wrapped } + +struct Envelope<'a> { + kind: Kind, + digest: [u8; 32], + bytes: Option<&'a [u8]>, + text: Option<&'a str>, + header: Option<&'a str>, +} + +struct Cbor<'a> { + input: &'a [u8], + offset: usize, + items_left: usize, + headers: Vec<&'a str>, + subjects: BTreeMap<[u8; 32], &'a [u8]>, +} + +impl<'a> Cbor<'a> { + fn head(&mut self, depth: usize) -> Result<(u8, u64), String> { + if depth > 16 || self.items_left == 0 || self.offset >= self.input.len() { + return Err("invalid CBOR bounds".into()); + } + self.items_left -= 1; + let initial = self.input[self.offset]; + self.offset += 1; + let major = initial >> 5; + let additional = initial & 31; + if additional >= 28 || (major == 7 && additional > 23) { + return Err("unsupported CBOR value".into()); + } + let mut argument = u64::from(additional); + if additional >= 24 { + let size = 1usize << (additional - 24); + if size > self.input.len() - self.offset { return Err("truncated CBOR argument".into()); } + argument = 0; + for byte in &self.input[self.offset..self.offset + size] { + argument = argument * 256 + u64::from(*byte); + } + self.offset += size; + if argument > MAX_SAFE_INTEGER || argument < [24, 256, 65_536, 4_294_967_296][usize::from(additional - 24)] { + return Err("noncanonical CBOR integer".into()); + } + } + Ok((major, argument)) + } + + fn data(&mut self, length: u64) -> Result<&'a [u8], String> { + if length > (self.input.len() - self.offset) as u64 { return Err("truncated CBOR bytes".into()); } + let start = self.offset; + self.offset += length as usize; + Ok(&self.input[start..self.offset]) + } + + fn text(bytes: &'a [u8]) -> Result<&'a str, String> { + let text = std::str::from_utf8(bytes).map_err(|_| "invalid CBOR UTF-8")?; + if !text.nfc().eq(text.chars()) { return Err("noncanonical CBOR text".into()); } + Ok(text) + } + + fn skip(&mut self, depth: usize) -> Result<(), String> { + let (major, argument) = self.head(depth)?; + match major { + 0 | 1 => {}, + 2 => { self.data(argument)?; }, + 3 => { Self::text(self.data(argument)?)?; }, + 4 | 5 => { + let count = argument.checked_mul(if major == 5 { 2 } else { 1 }).ok_or("CBOR item limit")?; + if count > self.items_left as u64 { return Err("CBOR item limit".into()); } + let mut prior_key: Option<&[u8]> = None; + for index in 0..count { + let start = self.offset; + self.skip(depth + 1)?; + if major == 5 && index % 2 == 0 { + let key = &self.input[start..self.offset]; + if prior_key.is_some_and(|prior| prior >= key) { return Err("noncanonical CBOR map".into()); } + prior_key = Some(key); + } + } + }, + 6 => self.skip(depth + 1)?, + 7 if matches!(argument, 20..=22) => {}, + _ => return Err("unsupported CBOR simple value".into()), + } + Ok(()) + } + + fn envelope(&mut self, depth: usize) -> Result, String> { + let (major, argument) = self.head(depth)?; + let mut result = Envelope { kind: Kind::Leaf, digest: [0; 32], bytes: None, text: None, header: None }; + match (major, argument) { + (6, 201) => { + let start = self.offset; + // Inspect simple leaf values without building a CBOR value tree. + let saved_items = self.items_left; + let (leaf_major, length) = self.head(depth + 1)?; + match leaf_major { + 2 => result.bytes = Some(self.data(length)?), + 3 => result.text = Some(Self::text(self.data(length)?)?), + _ => { + self.offset = start; + self.items_left = saved_items; + self.skip(depth + 1)?; + }, + } + result.digest = Sha256::digest(&self.input[start..self.offset]).into(); + if let Some(bytes) = result.bytes { + let digest: [u8; 32] = Sha256::digest(bytes).into(); + if self.subjects.get(&digest).is_some_and(|prior| *prior != bytes) { + return Err("contradictory byte witness".into()); + } + self.subjects.insert(digest, bytes); + } + }, + (2, 32) => { + result.kind = Kind::Elided; + result.digest.copy_from_slice(self.data(32)?); + }, + (5, 1) => { + let predicate = self.envelope(depth + 1)?; + let object = self.envelope(depth + 1)?; + result.kind = Kind::Assertion; + let mut hash = Sha256::new(); + hash.update(predicate.digest); + hash.update(object.digest); + result.digest = hash.finalize().into(); + if predicate.text == Some(PREDICATE) { + if predicate.kind != Kind::Leaf || object.kind != Kind::Leaf || object.text.is_none() { + return Err("ambiguous notification assertion".into()); + } + result.header = object.text; + if self.headers.len() >= MAX_NOTIFICATION_CANDIDATES { return Err("notification candidate limit".into()); } + self.headers.push(object.text.ok_or("invalid notification header object")?); + } + }, + (6, 200) => { + result.kind = Kind::Wrapped; + result.digest = Sha256::digest(self.envelope(depth + 1)?.digest).into(); + }, + (4, 2..=129) => { + let subject = self.envelope(depth + 1)?; + let mut hash = Sha256::new(); + hash.update(subject.digest); + result.kind = Kind::Node; + result.text = subject.text; + let mut prior: Option<[u8; 32]> = None; + for _ in 1..argument { + let assertion = self.envelope(depth + 1)?; + if !matches!(assertion.kind, Kind::Assertion | Kind::Elided) || prior.is_some_and(|digest| digest >= assertion.digest) { + return Err("invalid Envelope assertion order or structure".into()); + } + if let Some(header) = assertion.header { + if result.header.is_some() { return Err("duplicate notification assertion".into()); } + result.header = Some(header); + } + prior = Some(assertion.digest); + hash.update(assertion.digest); + } + result.digest = hash.finalize().into(); + }, + _ => return Err("unsupported Envelope structure".into()), + } + Ok(result) + } +} + +fn extract(frame: &[u8]) -> Result, String> { + if frame.len() > MAX_FULL_FRAME_BYTES { return Err("notification container too large".into()); } + let mut reader = Cbor { input: frame, offset: 0, items_left: 4096, headers: Vec::new(), subjects: BTreeMap::new() }; + if reader.head(0)? != (6, 200) { return Err("not a Gordian Envelope".into()); } + reader.envelope(1)?; + if reader.offset != frame.len() { return Err("trailing notification container data".into()); } + Ok(reader) +} + + +/// Verify all eligible candidates with a byte-leaf membership witness in the carrier. +/// Malformed containers fail as a whole. Invalid candidate headers/proofs are omitted. +/// Callers separately enforce product authority, approved senders, mute and replay policy. +pub fn verify_frames( + frame: &[u8], actual_genesis: &str, actual_channel: &str, + actual_topics: &[String], now_ms: u64, +) -> Result, String> { + if now_ms > MAX_SAFE_INTEGER { return Err("invalid notification clock".into()); } + let candidates = extract(frame)?; + Ok(candidates.headers.iter().filter_map(|json| { + verify_candidate(json, &candidates.subjects, actual_genesis, actual_channel, actual_topics, now_ms).ok() + }).collect()) +} + +fn verify_candidate( + json: &str, subjects: &BTreeMap<[u8; 32], &[u8]>, actual_genesis: &str, actual_channel: &str, + actual_topics: &[String], now_ms: u64, +) -> Result { + if json.len() > MAX_HEADER_BYTES { return Err("notification header too large".into()); } + let header: NotificationHeader = serde_json::from_str(json).map_err(|error| format!("invalid notification JSON: {error}"))?; + validate_header(&header)?; + let mut digest = [0u8; 32]; + hex::decode_to_slice(&header.ciphertext_digest, &mut digest).map_err(|error| error.to_string())?; + let carrier = *subjects.get(&digest).ok_or("missing notification byte witness")?; + if carrier.len() > MAX_CARRIER_BYTES { return Err("notification carrier too large".into()); } + if now_ms > MAX_SAFE_INTEGER || header.created_at > now_ms.saturating_add(FUTURE_SKEW_MS) || header.expires_at <= now_ms { + return Err("notification outside validity window".into()); + } + if header.genesis != actual_genesis || header.channel != actual_channel + || !(1..=4).contains(&actual_topics.len()) + || actual_topics.iter().enumerate().any(|(index, topic)| !canonical_hex(topic, 32) || actual_topics[..index].contains(topic)) + || header.topics.iter().any(|topic| !actual_topics.contains(topic)) { + return Err("notification source mismatch".into()); + } + let mut key_bytes = [0u8; 32]; + let mut signature_bytes = [0u8; 64]; + hex::decode_to_slice(&header.sender_key, &mut key_bytes).map_err(|error| error.to_string())?; + hex::decode_to_slice(&header.signature, &mut signature_bytes).map_err(|error| error.to_string())?; + let mut field_modulus = [0xff; 32]; + field_modulus[0] = 0xed; + field_modulus[31] = 0x7f; + for compressed in [&key_bytes[..], &signature_bytes[..32]] { + let mut coordinate = [0u8; 32]; + coordinate.copy_from_slice(compressed); + coordinate[31] &= 0x7f; + if coordinate.iter().rev().cmp(field_modulus.iter().rev()) != std::cmp::Ordering::Less { return Err("noncanonical Ed25519 point".into()); } + } + let key = VerifyingKey::from_bytes(&key_bytes).map_err(|error| error.to_string())?; + key.verify_strict(&signing_bytes(&header)?, &Signature::from_bytes(&signature_bytes)).map_err(|_| "invalid notification signature".to_owned())?; + Ok(VerifiedNotification { header }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn verify_frame( + frame: &[u8], actual_genesis: &str, actual_channel: &str, + actual_topics: &[String], now_ms: u64, + ) -> Result { + let candidates = extract(frame)?; + if candidates.headers.len() != 1 { return Err("expected exactly one notification candidate".into()); } + verify_candidate(candidates.headers[0], &candidates.subjects, actual_genesis, actual_channel, actual_topics, now_ms) + } + + // Independent OpenSSL vector, raw seed 00..1f and byte witness "abc". + const VECTOR: &str = r#"{"v":1,"product":"example.paseo","genesis":"1111111111111111111111111111111111111111111111111111111111111111","channel":"5555555555555555555555555555555555555555555555555555555555555555","topics":["2222222222222222222222222222222222222222222222222222222222222222","3333333333333333333333333333333333333333333333333333333333333333"],"eventId":"4444444444444444444444444444444444444444444444444444444444444444","createdAt":1700000000000,"expiresAt":1700000060000,"ciphertextDigest":"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad","senderKey":"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8","signature":"6bc192199982a1b8e850b66b1f0cd85035354e0b788edecddfad7505da94c7347447b5fa4c9a64647045eea6d52e4aecec14dcb16ea64de317b1b3e76b0f830b"}"#; + const SIGNED: &str = r#"["truapi:notification:v1",1,"example.paseo","1111111111111111111111111111111111111111111111111111111111111111","5555555555555555555555555555555555555555555555555555555555555555",["2222222222222222222222222222222222222222222222222222222222222222","3333333333333333333333333333333333333333333333333333333333333333"],"4444444444444444444444444444444444444444444444444444444444444444",1700000000000,1700000060000,"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad","03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8"]"#; + const NOW: u64 = 1_700_000_000_000; + + struct Element { bytes: Vec, digest: [u8; 32] } + + fn head(major: u8, length: usize) -> Vec { + if length < 24 { return vec![(major << 5) | length as u8]; } + if length < 256 { return vec![(major << 5) | 24, length as u8]; } + if length < 65_536 { + let mut bytes = vec![(major << 5) | 25]; + bytes.extend_from_slice(&(length as u16).to_be_bytes()); + return bytes; + } + let mut bytes = vec![(major << 5) | 26]; + bytes.extend_from_slice(&(length as u32).to_be_bytes()); + bytes + } + + fn leaf(major: u8, value: &[u8]) -> Element { + let mut raw = head(major, value.len()); + raw.extend_from_slice(value); + let digest = Sha256::digest(&raw).into(); + let mut bytes = vec![0xd8, 201]; + bytes.extend(raw); + Element { bytes, digest } + } + + fn assertion(predicate: &str, object: Element) -> Element { + let key = leaf(3, predicate.as_bytes()); + let mut bytes = vec![0xa1]; + bytes.extend(key.bytes); + bytes.extend(object.bytes); + let mut hash = Sha256::new(); + hash.update(key.digest); + hash.update(object.digest); + Element { bytes, digest: hash.finalize().into() } + } + + fn node(subject: Element, mut assertions: Vec) -> Element { + assertions.sort_by_key(|entry| entry.digest); + let mut bytes = head(4, assertions.len() + 1); + bytes.extend(subject.bytes); + let mut hash = Sha256::new(); + hash.update(subject.digest); + for entry in assertions { + bytes.extend(entry.bytes); + hash.update(entry.digest); + } + Element { bytes, digest: hash.finalize().into() } + } + + fn tagged(element: Element) -> Vec { + let mut bytes = vec![0xd8, 200]; + bytes.extend(element.bytes); + bytes + } + + fn frame(json: &str, body: &[u8]) -> Vec { + tagged(node(leaf(2, body), vec![assertion(PREDICATE, leaf(3, json.as_bytes()))])) + } + + fn verify(bytes: &[u8]) -> Result { + verify_frame(bytes, &"11".repeat(32), &"55".repeat(32), &["22".repeat(32), "33".repeat(32)], NOW) + } + + #[test] + fn independent_cross_language_vector() { + let bytes = frame(VECTOR, b"abc"); + let verified = verify(&bytes).unwrap(); + assert_eq!(signing_bytes(&verified.header).unwrap(), SIGNED.as_bytes()); + let actual_topics = ["66".repeat(32), "33".repeat(32), "22".repeat(32)]; + assert!(verify_frame(&frame(VECTOR, b"abc"), &"11".repeat(32), &"55".repeat(32), &actual_topics, NOW).is_ok()); + } + + #[test] + fn resolves_generic_byte_membership_and_independent_siblings() { + let prior = node(leaf(3, b"unrelated subject"), vec![ + assertion("opaque-slot", leaf(2, b"abc")), + assertion(PREDICATE, leaf(3, VECTOR.as_bytes())), + ]); + let forged_json = VECTOR.replace("6bc19219", "00000000"); + let forged = node(leaf(3, b"another subject"), vec![assertion(PREDICATE, leaf(3, forged_json.as_bytes()))]); + let bytes = tagged(node(leaf(3, b"control"), vec![assertion("first", prior), assertion("second", forged)])); + let verified = verify_frames(&bytes, &"11".repeat(32), &"55".repeat(32), &["22".repeat(32), "33".repeat(32)], NOW).unwrap(); + assert_eq!(verified.len(), 1); + assert_eq!(signing_bytes(&verified[0].header).unwrap(), SIGNED.as_bytes()); + assert!(verify(&frame(VECTOR, b"wrong")).is_err()); + } + + #[test] + fn rejects_ambiguous_json_and_schema() { + for json in [ + VECTOR.replace("\"v\":1", "\"v\":1,\"v\":1"), + VECTOR.replace("\"v\":1", "\"v\":1,\"\\u0076\":1"), + VECTOR.replace("\"v\":1", "\"v\":1,\"extra\":false"), + VECTOR.replace("\"v\":1,", ""), + VECTOR.replace("\"v\":1", "\"v\":1.0"), + VECTOR.replace("\"v\":1", "\"v\":1e0"), + VECTOR.replace("\"v\":1", "\"v\":-0"), + format!("{VECTOR} trailing"), + ] { assert!(verify(&frame(&json, b"abc")).is_err(), "{json}"); } + } + + #[test] + fn rejects_invalid_metadata_proofs_and_sources() { + for (field, value) in [ + ("product", serde_json::json!("😀")), ("product", serde_json::json!("A")), + ("product", serde_json::json!("x".repeat(129))), ("product", serde_json::json!("")), + ("genesis", serde_json::json!("AA".repeat(32))), ("channel", serde_json::json!("00".repeat(32))), + ("eventId", serde_json::json!("66".repeat(32))), ("topics", serde_json::json!([])), + ("topics", serde_json::json!(vec!["22".repeat(32); 5])), + ("topics", serde_json::json!(vec!["22".repeat(32); 2])), + ("createdAt", serde_json::json!(MAX_SAFE_INTEGER + 1)), ("createdAt", serde_json::json!(-1)), + ("expiresAt", serde_json::json!(NOW)), ("expiresAt", serde_json::json!(NOW + MAX_TTL_MS + 1)), + ("signature", serde_json::json!("00".repeat(64))), ("senderKey", serde_json::json!("00".repeat(32))), + ("ciphertextDigest", serde_json::json!("00".repeat(32))), + ] { + let mut header: serde_json::Value = serde_json::from_str(VECTOR).unwrap(); + header[field] = value; + assert!(verify(&frame(&header.to_string(), b"abc")).is_err(), "{field}"); + } + let bytes = frame(VECTOR, b"abc"); + let topics = ["22".repeat(32), "33".repeat(32)]; + assert!(verify_frame(&bytes, &"00".repeat(32), &"55".repeat(32), &topics, NOW).is_err()); + assert!(verify_frame(&bytes, &"11".repeat(32), &"00".repeat(32), &topics, NOW).is_err()); + assert!(verify_frame(&bytes, &"11".repeat(32), &"55".repeat(32), &topics[..1], NOW).is_err()); + assert!(verify_frame(&bytes, &"11".repeat(32), &"55".repeat(32), &topics, NOW + 60_000).is_err()); + assert!(verify_frame(&bytes, &"11".repeat(32), &"55".repeat(32), &topics, NOW - FUTURE_SKEW_MS - 1).is_err()); + } + + #[test] + fn rejects_container_ambiguities_and_resource_exhaustion() { + assert!(verify(&[]).is_err()); + assert!(verify(&vec![0; MAX_FULL_FRAME_BYTES + 1]).is_err()); + assert!(verify(&frame(&" ".repeat(MAX_HEADER_BYTES + 1), b"abc")).is_err()); + assert!(verify(&frame(VECTOR, &vec![0; MAX_CARRIER_BYTES + 1])).is_err()); + let mut trailing = frame(VECTOR, b"abc"); trailing.push(0); + assert!(verify(&trailing).is_err()); + let canonical = frame(VECTOR, b"abc"); + let mut noncanonical = vec![0xd9, 0, 200]; noncanonical.extend_from_slice(&canonical[2..]); + assert!(verify(&noncanonical).is_err()); + let duplicate = tagged(node(leaf(2, b"abc"), vec![ + assertion(PREDICATE, leaf(3, VECTOR.as_bytes())), assertion(PREDICATE, leaf(3, b"different")), + ])); + assert!(verify(&duplicate).is_err()); + let candidates = (0..33).map(|index| assertion(&format!("entry-{index}"), node(leaf(3, b"subject"), vec![assertion(PREDICATE, leaf(3, VECTOR.as_bytes()))]))).collect(); + assert!(verify(&tagged(node(leaf(2, b"abc"), candidates))).is_err()); + let mut nested = leaf(2, b"abc"); + for _ in 0..20 { nested = node(leaf(3, b"subject"), vec![assertion("nested", nested)]); } + assert!(verify(&tagged(nested)).is_err()); + } +} + +#[cfg(test)] +mod strict_equation_tests { + use super::*; + + #[test] + fn rejects_cofactor_only_proof_but_accepts_exact_mixed_key_proof() { + // Independent scalar a=r=1 fixtures with order-two T=(0,-1). + let mut header = NotificationHeader { + v: 1, product: "example.paseo".into(), genesis: "11".repeat(32), + channel: "55".repeat(32), topics: vec!["22".repeat(32), "33".repeat(32)], + event_id: "44".repeat(32), created_at: 1_700_000_000_000, expires_at: 1_700_000_060_000, + ciphertext_digest: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad".into(), + sender_key: "5866666666666666666666666666666666666666666666666666666666666666".into(), + signature: "95999999999999999999999999999999999999999999999999999999999999994d0d311b42ae0fbd5231e2b7e106d734ca5e5045ba0882ac54c3f232e5718300".into(), + }; + let mut subjects = BTreeMap::new(); + subjects.insert(Sha256::digest(b"abc").into(), &b"abc"[..]); + assert!(verify_candidate(&serde_json::to_string(&header).unwrap(), &subjects, + &header.genesis, &header.channel, &header.topics, header.created_at).is_err()); + header.event_id = format!("{}04", "00".repeat(31)); + header.sender_key = "9599999999999999999999999999999999999999999999999999999999999999".into(); + header.signature = "58666666666666666666666666666666666666666666666666666666666666663114ba2ce5c96de9e15fbc99e889f60672480566a4420d0d7806399239ed5a06".into(); + assert!(verify_candidate(&serde_json::to_string(&header).unwrap(), &subjects, + &header.genesis, &header.channel, &header.topics, header.created_at).is_ok()); + } +} diff --git a/rust/crates/truapi/src/runtime/receiving.rs b/rust/crates/truapi/src/runtime/receiving.rs new file mode 100644 index 0000000000..096572b2b4 --- /dev/null +++ b/rust/crates/truapi/src/runtime/receiving.rs @@ -0,0 +1,794 @@ +//! Host-owned receiving policy. One service is the sole writer for one CoreStorage +//! namespace; browser window/worker adapters must route to the same owner, not +//! independently perform read/modify/write against the slot. No product lifetime +//! owns this service, and no transport request is awaited by product operations. + +use std::collections::HashSet; +use std::sync::{Arc, atomic::{AtomicBool, Ordering}}; +use futures::lock::Mutex; +use serde::{Deserialize, Serialize}; +use crate::latest::{HostNotificationReceiverStatus, HostNotificationReceiptResult, HostNotificationReceivingError as Error, + ReceivingEvent, ReceivingEventKind, ReceivingReceiptKind, ReceivingWatch}; +use crate::platform::{CoreStorageKey, Platform, ProductContext, ReceivingAuthority, ReceivingRegistration}; +use crate::subscription::Spawner; +use super::notification_envelope::verify_frames; + +const MAX_REGISTRATIONS: usize = 32; +const MAX_WATCHES: usize = 256; +const MAX_SENDERS: usize = 1_000; +const MAX_TOTAL_SENDERS: usize = 10_000; +const MAX_RECEIPTS: usize = 2_048; +const MAX_EVENTS: usize = 128; +const MAX_STATE_BYTES: usize = 32 * 1024 * 1024; +const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; +const DAY: u64 = 86_400_000; + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Ledger { + version: u8, + revision: u64, + sequence: u64, + records: Vec, +} +impl Default for Ledger { + fn default() -> Self { Self { version: 1, revision: 0, sequence: 0, records: Vec::new() } } +} +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Record { + authority: ReceivingAuthority, + revision: u64, + consent: bool, + enabled: bool, + sync_pending: bool, + watches: Vec, + receipts: Vec, + events: Vec, + display_attempts: Vec, +} +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Watch { + policy: ReceivingWatch, + not_before: u64, +} +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Receipt { + event_id: String, + watch_id: String, + expires_at: u64, + displayed: bool, + read: bool, + activated: bool, + reserved: bool, + foreground_seen: bool, +} + +/// Minimal trusted backend for a receiver-only browser worker. No wallet, +/// product execution or chain provider is required to validate incoming frames. +#[crate::platform::async_trait] +pub trait ReceivingBackend: Send + Sync { + /// Resolve the host's current verified scope; absence means unsupported. + async fn receiver_authority(&self, product: &str) -> Result, crate::latest::GenericError>; + /// Ask trusted UI for durable receiving consent over the disclosed policy. + async fn receiver_consent(&self, authority: ReceivingAuthority, watches: Vec) -> Result; + /// Wake an independent transport synchronizer; never perform network here. + async fn receiver_changed(&self) -> Result<(), crate::latest::GenericError>; + /// Read the opaque host-private receiving ledger. + async fn load(&self) -> Result>, crate::latest::GenericError>; + /// Atomically durably replace that ledger. Single writer is REQUIRED. + async fn save(&self, bytes: Vec) -> Result<(), crate::latest::GenericError>; +} + +struct PlatformBackend(Arc); + +#[crate::platform::async_trait] +impl ReceivingBackend for PlatformBackend { + async fn receiver_authority(&self, product: &str) -> Result, crate::latest::GenericError> { + self.0.receiver_authority(product).await + } + async fn receiver_consent(&self, authority: ReceivingAuthority, watches: Vec) -> Result { + self.0.receiver_consent(authority, watches).await + } + async fn receiver_changed(&self) -> Result<(), crate::latest::GenericError> { + self.0.receiver_changed().await + } + async fn load(&self) -> Result>, crate::latest::GenericError> { + self.0.read_core_storage(CoreStorageKey::NotificationReceiving).await + } + async fn save(&self, bytes: Vec) -> Result<(), crate::latest::GenericError> { + self.0.write_core_storage(CoreStorageKey::NotificationReceiving, bytes).await + } +} + +#[derive(Default)] +struct WakeState { + running: AtomicBool, + requested: AtomicBool, +} + +/// Resident host service. Reconstructing it reads the durable ledger on demand. +/// Authority callbacks must read local trusted state, including verified artifact +/// provenance. They must not use a product-supplied identity or network lookup. +pub struct ReceivingService { + platform: Arc, + gate: Mutex<()>, + spawner: Spawner, + wake_pending: Arc, +} +impl ReceivingService { + pub fn new(platform: Arc, spawner: Spawner) -> Self { + Self::from_backend(Arc::new(PlatformBackend(platform)), spawner) + } + + /// Construct a wallet-free receiver on a minimal trusted storage/authority backend. + pub fn from_backend(platform: Arc, spawner: Spawner) -> Self { + Self { platform, gate: Mutex::new(()), spawner, wake_pending: Arc::new(WakeState::default()) } + } + + /// Bind a call to immutable, host-verified execution provenance. The snapshot + /// is supplied only by the trusted connection adapter, never by product wire. + pub fn for_execution(&self, authority: ReceivingAuthority) -> ReceivingExecution<'_> { + ReceivingExecution { service: self, authority } + } + + async fn authority_for(&self, product: &str, expected: Option<&ReceivingAuthority>) -> Result { + let current = self.authority(product).await?; + if expected.is_some_and(|expected| !same_authority(¤t, expected)) { + return Err(Error::PermissionDenied); + } + Ok(current) + } + + async fn authority(&self, product: &str) -> Result { + ProductContext::new(product.to_owned()).map_err(|_| invalid("invalid product"))?; + let authority = self.platform.receiver_authority(product).await.map_err(storage)? + .ok_or(Error::Unsupported)?; + if authority.product_id != product || !hex32(&authority.account) || !hex32(&authority.artifact) + || !hex32(&authority.genesis) || authority.environment.is_empty() + || authority.environment.len() > 128 || authority.generation > MAX_SAFE_INTEGER { + return Err(Error::PermissionDenied); + } + Ok(authority) + } + + async fn load(&self) -> Result { + let Some(bytes) = self.platform.load() + .await.map_err(storage)? else { return Ok(Ledger::default()); }; + if bytes.len() > MAX_STATE_BYTES { return Err(invalid("receiving ledger exceeds budget")); } + let ledger: Ledger = serde_json::from_slice(&bytes).map_err(|_| invalid("invalid receiving ledger"))?; + if ledger.version != 1 || ledger.records.len() > MAX_REGISTRATIONS + || ledger.records.iter().any(|r| r.watches.len() > MAX_WATCHES || r.receipts.len() > MAX_RECEIPTS + || r.events.len() > MAX_EVENTS || r.display_attempts.len() > 60) + || ledger.revision > MAX_SAFE_INTEGER || ledger.sequence > MAX_SAFE_INTEGER { + return Err(invalid("unsupported receiving ledger")); + } + Ok(ledger) + } + + async fn save(&self, ledger: &Ledger) -> Result<(), Error> { + let bytes = serde_json::to_vec(ledger).map_err(|_| invalid("cannot encode receiving ledger"))?; + if bytes.len() > MAX_STATE_BYTES { return Err(Error::Capacity); } + self.platform.save(bytes).await.map_err(storage) + } + + fn wake(&self) { + self.wake_pending.requested.store(true, Ordering::Release); + if self.wake_pending.running.swap(true, Ordering::AcqRel) { return; } + let platform = self.platform.clone(); + let pending = self.wake_pending.clone(); + (self.spawner)(Box::pin(async move { + loop { + pending.requested.store(false, Ordering::Release); + // A hint, not a transport ACK. Failure leaves durable pending work. + let _ = platform.receiver_changed().await; + pending.running.store(false, Ordering::Release); + // A mutation during the hint must not lose the newer revision. + if !pending.requested.load(Ordering::Acquire) + || pending.running.swap(true, Ordering::AcqRel) { break; } + } + })); + } + + pub async fn status(&self, product: &str) -> Result { + self.status_scoped(product, None).await + } + + async fn status_scoped(&self, product: &str, expected: Option<&ReceivingAuthority>) -> Result { + let _guard = self.gate.lock().await; + let authority = match self.authority_for(product, expected).await { + Ok(value) => value, + Err(Error::Unsupported) => return Ok(HostNotificationReceiverStatus { + supported: false, os_permission: false, consent: false, enabled: false, + revision: 0, sync_pending: false, transport_ready: false, + }), + Err(error) => return Err(error), + }; + let ledger = self.load().await?; + Ok(status(&authority, find(&ledger, &authority))) + } + + /// Local CAS commit; explicit durable receiving consent is not an OS grant. + pub async fn replace(&self, product: &str, expected_revision: u64, watches: Vec) + -> Result { + self.replace_scoped(product, expected_revision, watches, None).await + } + + async fn replace_scoped(&self, product: &str, expected_revision: u64, watches: Vec, + expected: Option<&ReceivingAuthority>) -> Result { + let authority = self.authority_for(product, expected).await?; + if !authority.os_permission { return Err(Error::PermissionDenied); } + validate_watches(&watches, &authority, now())?; + let (fence, needs_consent) = { + let _guard = self.gate.lock().await; + let ledger = self.load().await?; + let record = find(&ledger, &authority); + check_revision(record, expected_revision)?; + (ledger.revision, !record.is_some_and(|r| current(r, &authority) && r.consent + && watches.iter().all(|w| r.watches.iter().any(|old| within_consent(w, &old.policy))))) + }; + if needs_consent && !self.platform.receiver_consent(authority.clone(), watches.clone()) + .await.map_err(storage)? { return Err(Error::PermissionDenied); } + let _guard = self.gate.lock().await; + let fresh = self.authority_for(product, expected).await?; + if !same_authority(&fresh, &authority) || !fresh.os_permission { return Err(Error::PermissionDenied); } + let mut ledger = self.load().await?; + // Revocation, account replacement or a concurrent first enrollment during + // a prompt fences the result, including when no record existed yet. + if ledger.revision != fence { return Err(Error::Conflict); } + check_revision(find(&ledger, &authority), expected_revision)?; + let timestamp = now(); + validate_watches(&watches, &authority, timestamp)?; + let position = ledger.records.iter().position(|r| same_scope(&r.authority, &authority)); + if position.is_none() && ledger.records.len() >= MAX_REGISTRATIONS { return Err(Error::Capacity); } + // A product's previous account/artifact cannot keep receiving accidentally. + for index in 0..ledger.records.len() { + if ledger.records[index].authority.product_id == product + && !same_scope(&ledger.records[index].authority, &authority) { + let retired_revision = next_revision(&mut ledger)?; + disable_record(&mut ledger.records[index], retired_revision); + } + } + // Each outbox entry has its own ACK token, including retired scopes. + let revision = next_revision(&mut ledger)?; + let position = position.unwrap_or_else(|| { + ledger.records.push(Record { authority: authority.clone(), revision: 0, consent: false, + enabled: false, sync_pending: false, watches: Vec::new(), receipts: Vec::new(), events: Vec::new(), + display_attempts: Vec::new() }); + ledger.records.len() - 1 + }); + let record = &mut ledger.records[position]; + let policies = watches.into_iter().map(|policy| { + let old = record.watches.iter().find(|w| w.policy.id == policy.id); + let not_before = old.filter(|w| current(record, &authority) + && w.policy.genesis == policy.genesis && w.policy.channel == policy.channel && w.policy.topics == policy.topics + && w.policy.senders == policy.senders && w.policy.muted_until == policy.muted_until) + .map_or(timestamp, |w| w.not_before); + Watch { policy, not_before } + }).collect(); + record.authority = fresh; + record.watches = policies; + record.revision = revision; + record.consent = true; + record.enabled = !record.watches.is_empty(); + record.sync_pending = true; + // Old click handles are never reinterpreted under a new policy revision. + record.events.clear(); + prune(record, timestamp); + let result = status(&record.authority, Some(record)); + self.save(&ledger).await?; + self.wake(); + Ok(result) + } + + pub async fn disable(&self, product: &str, expected_revision: u64) + -> Result { + self.disable_scoped(product, expected_revision, None).await + } + + async fn disable_scoped(&self, product: &str, expected_revision: u64, + expected: Option<&ReceivingAuthority>) -> Result { + let _guard = self.gate.lock().await; + let authority = self.authority_for(product, expected).await?; + let mut ledger = self.load().await?; + check_revision(find(&ledger, &authority), expected_revision)?; + let revision = next_revision(&mut ledger)?; + for record in &mut ledger.records { + if same_scope(&record.authority, &authority) { disable_record(record, revision); } + } + let result = status(&authority, find(&ledger, &authority)); + self.save(&ledger).await?; + self.wake(); + Ok(result) + } + + /// Trusted logout/deletion path, independent of active account availability. + /// Invoke before deleting account data. A failed durable write is an error. + pub async fn revoke(&self, product: &str) -> Result<(), Error> { + let _guard = self.gate.lock().await; + let mut ledger = self.load().await?; + next_revision(&mut ledger)?; // Fence even a first enrollment still in consent. + for index in 0..ledger.records.len() { + if ledger.records[index].authority.product_id == product { + let revision = next_revision(&mut ledger)?; + disable_record(&mut ledger.records[index], revision); + } + } + self.save(&ledger).await?; + self.wake(); + Ok(()) + } + + /// Explicit host logout revokes locally without contacting any transport. + /// Ordinary runtime disposal must not call this method. + pub async fn revoke_all(&self) -> Result<(), Error> { + let _guard = self.gate.lock().await; + let mut ledger = self.load().await?; + next_revision(&mut ledger)?; + for index in 0..ledger.records.len() { + let revision = next_revision(&mut ledger)?; + disable_record(&mut ledger.records[index], revision); + } + self.save(&ledger).await?; + self.wake(); + Ok(()) + } + + /// Provider token rotation invalidates old synchronization results and handles. + pub async fn mark_transport_changed(&self, product: &str) -> Result<(), Error> { + let _guard = self.gate.lock().await; + let mut ledger = self.load().await?; + next_revision(&mut ledger)?; + for index in 0..ledger.records.len() { + if ledger.records[index].authority.product_id == product { + let revision = next_revision(&mut ledger)?; + let record = &mut ledger.records[index]; + record.revision = revision; + record.sync_pending = true; + // Rotation is not a new watch policy: preserve accepted events + // and replay receipts, but fence stale transport acknowledgements. + for event in &mut record.events { event.revision = revision; } + } + } + self.save(&ledger).await?; + self.wake(); + Ok(()) + } + + /// Local outbox snapshot. Transport must strip routes and use opaque provider + /// handles. Never send the artifact/account to a provider as notification text. + pub async fn pending(&self) -> Result, Error> { + let _guard = self.gate.lock().await; + let mut ledger = self.load().await?; + let mut changed = false; + let mut available = [true; MAX_REGISTRATIONS]; + for index in 0..ledger.records.len() { + if !ledger.records[index].enabled { continue; } + let product = &ledger.records[index].authority.product_id; + let live = self.authority(product).await; + if live.is_err() { + available[index] = false; + continue; + } + // A temporarily unavailable/locked authority pauses local handling; + // only a positively resolved changed scope invalidates the grant. + if live.as_ref().is_ok_and(|a| !current(&ledger.records[index], a) || !a.os_permission) { + let revision = next_revision(&mut ledger)?; + disable_record(&mut ledger.records[index], revision); + changed = true; + } + } + if changed { self.save(&ledger).await?; } + Ok(ledger.records.iter().enumerate().filter(|(index, _)| available[*index]).map(|(_, r)| ReceivingRegistration { + authority: r.authority.clone(), revision: r.revision, enabled: r.enabled, + watches: r.watches.iter().map(|w| w.policy.clone()).collect(), sync_pending: r.sync_pending, + }).collect()) + } + + pub async fn synchronized(&self, product: &str, revision: u64) -> Result { + let _guard = self.gate.lock().await; + let mut ledger = self.load().await?; + let mut matched = false; + for record in &mut ledger.records { + if record.authority.product_id == product && record.revision == revision && record.sync_pending { + record.sync_pending = false; + matched = true; + } + } + if matched { self.save(&ledger).await?; } + Ok(matched) + } + + pub async fn receipt(&self, product: &str, revision: u64, watch_id: String, event_id: String, + kind: ReceivingReceiptKind) -> Result { + self.receipt_scoped(product, revision, watch_id, event_id, kind, None).await + } + + async fn receipt_scoped(&self, product: &str, revision: u64, watch_id: String, event_id: String, + kind: ReceivingReceiptKind, expected: Option<&ReceivingAuthority>) -> Result { + if !hex32(&event_id) { return Err(invalid("invalid receipt event id")); } + let _guard = self.gate.lock().await; + let authority = self.authority_for(product, expected).await?; + let mut ledger = self.load().await?; + let record = find_mut(&mut ledger, &authority).ok_or(Error::Conflict)?; + require_record(record, &authority, revision)?; + let timestamp = now(); + if !record.watches.iter().any(|w| w.policy.id == watch_id && w.policy.expires_at > timestamp) { + return Err(invalid("unknown receipt watch")); + } + prune(record, timestamp); + let receipt = if let Some(index) = record.receipts.iter().position(|r| r.event_id == event_id) { + if record.receipts[index].watch_id != watch_id { return Err(invalid("receipt watch mismatch")); } + &mut record.receipts[index] + } else { + if record.receipts.len() >= MAX_RECEIPTS { return Err(Error::Capacity); } + record.receipts.push(Receipt { event_id: event_id.clone(), watch_id, expires_at: timestamp + DAY, + displayed: false, read: false, activated: false, reserved: false, foreground_seen: false }); + record.receipts.last_mut().expect("just inserted") + }; + match kind { + ReceivingReceiptKind::Foreground => receipt.foreground_seen = true, + ReceivingReceiptKind::Read => receipt.read = true, + ReceivingReceiptKind::Displayed => { + receipt.displayed = true; + receipt.reserved = false; + } + } + let result = HostNotificationReceiptResult { + displayed: receipt.displayed, + display_pending: receipt.reserved && !receipt.displayed, + }; + // Message catch-up can race the click that opened the product. Only an + // explicit event acknowledgement consumes that user's queued navigation. + record.events.retain(|event| event.event_id != event_id || event.kind == ReceivingEventKind::Activation); + self.save(&ledger).await?; + Ok(result) + } + + /// Release a local display reservation only when the platform knows its + /// presentation failed. Unknown/crashed outcomes stay pending until expiry: + /// neither a restart nor a timer is evidence that no OS alert was shown. + pub async fn cancel_display(&self, product: &str, revision: u64, event_id: String) -> Result<(), Error> { + let _guard = self.gate.lock().await; + let authority = self.authority(product).await?; + let mut ledger = self.load().await?; + let record = find_mut(&mut ledger, &authority).ok_or(Error::Conflict)?; + require_record(record, &authority, revision)?; + let receipt = record.receipts.iter_mut().find(|r| r.event_id == event_id && r.expires_at > now()) + .ok_or_else(|| invalid("unknown display handle"))?; + if receipt.displayed { return Ok(()); } + receipt.reserved = false; + self.save(&ledger).await + } + + /// Final local display gate after the host's foreground grace period. A + /// durable reservation prevents two callbacks from authorizing the same OS + /// alert. A crash after reservation may lose an alert, never duplicate it. + pub async fn prepare_display(&self, product: &str, revision: u64, event_id: String) + -> Result, Error> { + let _guard = self.gate.lock().await; + let authority = self.authority(product).await?; + let mut ledger = self.load().await?; + let record = find_mut(&mut ledger, &authority).ok_or(Error::Conflict)?; + require_record(record, &authority, revision)?; + if !authority.os_permission { return Err(Error::PermissionDenied); } + let timestamp = now(); + prune(record, timestamp); + let Some(event) = record.events.iter().find(|e| e.event_id == event_id + && e.revision == revision && e.kind == ReceivingEventKind::Delivery).cloned() + else { return Ok(None); }; + let Some(receipt) = record.receipts.iter_mut().find(|r| r.event_id == event_id) + else { return Ok(None); }; + if receipt.read || receipt.foreground_seen || receipt.displayed || receipt.reserved { return Ok(None); } + if !record.watches.iter().any(|w| w.policy.id == event.watch_id + && w.policy.expires_at > timestamp && w.policy.muted_until <= timestamp) { return Ok(None); } + if record.display_attempts.len() >= 60 { return Ok(None); } + receipt.reserved = true; + record.display_attempts.push(timestamp); + self.save(&ledger).await?; + Ok(Some(event)) + } + + /// Decode the canonical Statement Store shape, not an application codec. + /// The source genesis is supplied by the host's selected chain connection. + pub async fn ingest_statement(&self, product: &str, revision: u64, watch_id: String, + actual_genesis: String, statement: Vec) -> Result, Error> { + if statement.len() > 256 * 1024 { return Err(Error::Capacity); } + let statement = crate::host_logic::statement_store::decode_signed_statement(&statement) + .map_err(|_| invalid("invalid Statement Store statement"))?; + let expiry = statement.expiry.ok_or_else(|| invalid("statement has no expiry"))?; + if crate::host_logic::statement_store::statement_expiry_elapsed(expiry, now() / 1000) { + return Ok(Vec::new()); + } + let topics = statement.topics.iter().map(hex::encode).collect(); + let channel = hex::encode(statement.channel.ok_or_else(|| invalid("statement has no channel"))?); + let frame = statement.data.ok_or_else(|| invalid("statement has no frame"))?; + self.ingest(product, revision, watch_id, actual_genesis, channel, topics, frame).await + } + + /// Called only after the host actually displays an accepted delivery. + pub async fn confirm_display(&self, product: &str, revision: u64, event_id: String) -> Result<(), Error> { + let _guard = self.gate.lock().await; + let authority = self.authority(product).await?; + let mut ledger = self.load().await?; + let record = find_mut(&mut ledger, &authority).ok_or(Error::Conflict)?; + require_record(record, &authority, revision)?; + let receipt = record.receipts.iter_mut().find(|r| r.event_id == event_id && r.expires_at > now()) + .ok_or_else(|| invalid("unknown display handle"))?; + // APNs may already have rendered an advisory alert before our callback. + // This is a trusted report of actual display, never display permission. + if !receipt.reserved && !receipt.displayed && record.display_attempts.len() < 60 { + record.display_attempts.push(now()); + } + receipt.displayed = true; + receipt.reserved = false; + self.save(&ledger).await + } + + /// Validate actual source and the complete authenticated frame before minting + /// a local handle. Provider payload IDs alone must never call this path. + pub async fn ingest(&self, product: &str, revision: u64, watch_id: String, + actual_genesis: String, actual_channel: String, actual_topics: Vec, frame: Vec) + -> Result, Error> { + let _guard = self.gate.lock().await; + let authority = self.authority(product).await?; + let mut ledger = self.load().await?; + let record = find_mut(&mut ledger, &authority).ok_or(Error::Conflict)?; + require_record(record, &authority, revision)?; + if !authority.os_permission { return Err(Error::PermissionDenied); } + let timestamp = now(); + prune(record, timestamp); + let watch = record.watches.iter().find(|w| w.policy.id == watch_id) + .ok_or_else(|| invalid("unknown delivery watch"))?; + if watch.policy.expires_at <= timestamp || watch.policy.muted_until > timestamp { return Ok(Vec::new()); } + if watch.policy.genesis != actual_genesis || watch.policy.channel != actual_channel + || !watch.policy.topics.iter().all(|topic| actual_topics.contains(topic)) { + return Err(invalid("delivery source mismatch")); + } + let verified = verify_frames(&frame, &actual_genesis, &actual_channel, &actual_topics, timestamp) + .map_err(|reason| Error::InvalidRequest { reason })?; + let mut accepted = Vec::new(); + for candidate in verified { + let record = find_mut(&mut ledger, &authority).expect("record retained"); + let watch = record.watches.iter().find(|w| w.policy.id == watch_id).expect("watch retained"); + let header = candidate.header; + if header.product != product || !watch.policy.senders.contains(&header.sender_key) + || !watch.policy.topics.iter().all(|topic| header.topics.contains(topic)) + || header.created_at < watch.not_before || header.created_at < watch.policy.muted_until + || record.receipts.iter().any(|r| r.event_id == header.event_id) { continue; } + if record.receipts.len() >= MAX_RECEIPTS || record.events.len() >= MAX_EVENTS + || record.receipts.iter().filter(|r| !r.displayed && !r.foreground_seen && !r.read && !r.reserved).count() >= 4 { + // Never evict a live replay entry to admit new traffic. Commit + // the bounded accepted prefix, leaving excess candidates fresh. + break; + } + let route = watch.policy.route.clone(); + let expires_at = header.expires_at.min(watch.policy.expires_at); + record.receipts.push(Receipt { event_id: header.event_id.clone(), watch_id: watch_id.clone(), + expires_at: header.expires_at, displayed: false, read: false, activated: false, reserved: false, foreground_seen: false }); + let sequence = next_sequence(&mut ledger)?; + let event = ReceivingEvent { sequence, revision, watch_id: watch_id.clone(), event_id: header.event_id, + kind: ReceivingEventKind::Delivery, route, expires_at }; + find_mut(&mut ledger, &authority).expect("record retained").events.push(event.clone()); + accepted.push(event); + } + if !accepted.is_empty() { + let fresh = self.authority(product).await?; + if !same_authority(&fresh, &authority) || !fresh.os_permission { return Err(Error::PermissionDenied); } + self.save(&ledger).await?; + } + Ok(accepted) + } + + /// Preview a current click handle before opening the verified product. + /// Sequence zero is a preview, not an event; call activate after readiness. + pub async fn validate_activation(&self, product: &str, revision: u64, event_id: String) + -> Result, Error> { + let _guard = self.gate.lock().await; + let authority = self.authority(product).await?; + let ledger = self.load().await?; + let record = find(&ledger, &authority).ok_or(Error::Conflict)?; + require_record(record, &authority, revision)?; + let timestamp = now(); + let Some(receipt) = record.receipts.iter().find(|r| r.event_id == event_id + && r.expires_at > timestamp && !r.read && !r.activated) else { return Ok(None); }; + let Some(watch) = record.watches.iter().find(|w| w.policy.id == receipt.watch_id + && w.policy.expires_at > timestamp && w.policy.muted_until <= timestamp) else { return Ok(None); }; + Ok(Some(ReceivingEvent { sequence: 0, revision, watch_id: receipt.watch_id.clone(), + event_id, kind: ReceivingEventKind::Activation, route: watch.policy.route.clone(), + expires_at: receipt.expires_at.min(watch.policy.expires_at) })) + } + + /// User activation resolves only a durable accepted local handle. The shell + /// may focus/open its verified product; it must never auto-switch accounts. + pub async fn activate(&self, product: &str, revision: u64, event_id: String) + -> Result, Error> { + let _guard = self.gate.lock().await; + let authority = self.authority(product).await?; + let mut ledger = self.load().await?; + let record = find_mut(&mut ledger, &authority).ok_or(Error::Conflict)?; + require_record(record, &authority, revision)?; + let timestamp = now(); + prune(record, timestamp); + let receipt = record.receipts.iter_mut().find(|r| r.event_id == event_id) + .ok_or_else(|| invalid("unknown activation handle"))?; + if receipt.read || receipt.activated { return Ok(None); } + let watch = record.watches.iter().find(|w| w.policy.id == receipt.watch_id + && w.policy.expires_at > timestamp && w.policy.muted_until <= timestamp) + .ok_or(Error::PermissionDenied)?; + if record.events.len() >= MAX_EVENTS { return Err(Error::Capacity); } + let watch_id = receipt.watch_id.clone(); + let route = watch.policy.route.clone(); + let expires_at = receipt.expires_at.min(watch.policy.expires_at); + receipt.activated = true; + let sequence = next_sequence(&mut ledger)?; + let event = ReceivingEvent { sequence, revision, watch_id, event_id, + kind: ReceivingEventKind::Activation, route, expires_at }; + find_mut(&mut ledger, &authority).expect("record retained").events.push(event.clone()); + self.save(&ledger).await?; + Ok(Some(event)) + } + + pub async fn events(&self, product: &str, after_sequence: u64) -> Result, Error> { + self.events_scoped(product, after_sequence, None).await + } + + async fn events_scoped(&self, product: &str, after_sequence: u64, expected: Option<&ReceivingAuthority>) -> Result, Error> { + let _guard = self.gate.lock().await; + let authority = self.authority_for(product, expected).await?; + let ledger = self.load().await?; + let Some(record) = find(&ledger, &authority) else { return Ok(Vec::new()); }; + if !current(record, &authority) || !record.enabled || !record.consent { return Ok(Vec::new()); } + let timestamp = now(); + Ok(record.events.iter().filter(|e| e.sequence > after_sequence && e.expires_at > timestamp + && e.revision == record.revision).cloned().collect()) + } + + /// Acknowledge exactly one event; unrelated delivery/activation stays queued. + pub async fn acknowledge(&self, product: &str, sequence: u64) -> Result<(), Error> { + self.acknowledge_scoped(product, sequence, None).await + } + + async fn acknowledge_scoped(&self, product: &str, sequence: u64, expected: Option<&ReceivingAuthority>) -> Result<(), Error> { + let _guard = self.gate.lock().await; + let authority = self.authority_for(product, expected).await?; + let mut ledger = self.load().await?; + let record = find_mut(&mut ledger, &authority).ok_or(Error::Conflict)?; + if !current(record, &authority) { return Err(Error::PermissionDenied); } + record.events.retain(|event| event.sequence != sequence); + self.save(&ledger).await + } +} + +fn now() -> u64 { + #[cfg(not(target_arch = "wasm32"))] + use std::time::{SystemTime, UNIX_EPOCH}; + #[cfg(target_arch = "wasm32")] + use web_time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64 +} +fn invalid(reason: &str) -> Error { Error::InvalidRequest { reason: reason.to_owned() } } +fn storage(error: crate::latest::GenericError) -> Error { Error::Storage { reason: error.reason } } +fn hex32(value: &str) -> bool { value.len() == 64 && value.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) } +fn same_scope(a: &ReceivingAuthority, b: &ReceivingAuthority) -> bool { + a.product_id == b.product_id && a.account == b.account && a.environment == b.environment + && a.artifact == b.artifact && a.genesis == b.genesis +} +fn same_authority(a: &ReceivingAuthority, b: &ReceivingAuthority) -> bool { same_scope(a, b) && a.generation == b.generation } +fn current(record: &Record, authority: &ReceivingAuthority) -> bool { same_authority(&record.authority, authority) } +fn find<'a>(ledger: &'a Ledger, authority: &ReceivingAuthority) -> Option<&'a Record> { + ledger.records.iter().find(|r| same_scope(&r.authority, authority)) +} +fn find_mut<'a>(ledger: &'a mut Ledger, authority: &ReceivingAuthority) -> Option<&'a mut Record> { + ledger.records.iter_mut().find(|r| same_scope(&r.authority, authority)) +} +fn check_revision(record: Option<&Record>, expected: u64) -> Result<(), Error> { + if record.map_or(0, |r| r.revision) != expected { Err(Error::Conflict) } else { Ok(()) } +} +fn next_revision(ledger: &mut Ledger) -> Result { + ledger.revision = ledger.revision.checked_add(1).filter(|n| *n <= MAX_SAFE_INTEGER).ok_or(Error::Capacity)?; + Ok(ledger.revision) +} +fn next_sequence(ledger: &mut Ledger) -> Result { + ledger.sequence = ledger.sequence.checked_add(1).filter(|n| *n <= MAX_SAFE_INTEGER).ok_or(Error::Capacity)?; + Ok(ledger.sequence) +} +fn disable_record(record: &mut Record, revision: u64) { + record.revision = revision; + record.enabled = false; + record.consent = false; + record.sync_pending = true; + record.watches.clear(); + record.events.clear(); +} +fn require_record(record: &Record, authority: &ReceivingAuthority, revision: u64) -> Result<(), Error> { + if record.revision != revision { return Err(Error::Conflict); } + if !current(record, authority) || !record.consent || !record.enabled { return Err(Error::PermissionDenied); } + Ok(()) +} +fn prune(record: &mut Record, timestamp: u64) { + record.receipts.retain(|r| r.expires_at > timestamp); + record.events.retain(|e| e.expires_at > timestamp); + record.display_attempts.retain(|at| at.saturating_add(3_600_000) > timestamp); +} +fn status(authority: &ReceivingAuthority, record: Option<&Record>) -> HostNotificationReceiverStatus { + let valid = record.is_some_and(|r| current(r, authority)); + HostNotificationReceiverStatus { supported: true, os_permission: authority.os_permission, + transport_ready: authority.transport_ready, consent: valid && record.is_some_and(|r| r.consent), + enabled: valid && authority.os_permission && record.is_some_and(|r| r.enabled), + revision: record.map_or(0, |r| r.revision), sync_pending: record.is_some_and(|r| r.sync_pending) } +} +fn within_consent(new: &ReceivingWatch, old: &ReceivingWatch) -> bool { + new.genesis == old.genesis && new.channel == old.channel && new.expires_at <= old.expires_at + && old.topics.iter().all(|topic| new.topics.contains(topic)) + && new.senders.iter().all(|key| old.senders.contains(key)) +} +fn validate_watches(watches: &[ReceivingWatch], authority: &ReceivingAuthority, timestamp: u64) -> Result<(), Error> { + if watches.len() > MAX_WATCHES { return Err(Error::Capacity); } + let mut ids = HashSet::with_capacity(watches.len()); + let mut senders = 0usize; + for watch in watches { + if watch.id.is_empty() || watch.id.len() > 128 || !watch.id.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_.:".contains(&b)) + || !ids.insert(&watch.id) { return Err(invalid("invalid or duplicate watch id")); } + if watch.genesis != authority.genesis || !hex32(&watch.genesis) { return Err(Error::PermissionDenied); } + if !hex32(&watch.channel) { return Err(invalid("invalid channel")); } + if watch.topics.is_empty() || watch.topics.len() > 4 || watch.topics.iter().any(|t| !hex32(t)) + || watch.topics.iter().collect::>().len() != watch.topics.len() { return Err(invalid("invalid topics")); } + senders += watch.senders.len(); + if watch.senders.is_empty() || watch.senders.len() > MAX_SENDERS || senders > MAX_TOTAL_SENDERS { return Err(Error::Capacity); } + if watch.senders.iter().any(|s| !hex32(s)) || watch.senders.iter().collect::>().len() != watch.senders.len() { + return Err(invalid("invalid sender policy")); + } + if watch.expires_at <= timestamp || watch.expires_at > timestamp.saturating_add(30 * DAY) + || watch.expires_at > MAX_SAFE_INTEGER || (watch.muted_until > MAX_SAFE_INTEGER && watch.muted_until != u64::MAX) { + return Err(invalid("invalid watch timestamps")); + } + // Conservative relative routes: reject encoding tricks, separators and + // traversal rather than interpreting them differently in native/browser. + if watch.route.len() > 512 || !watch.route.starts_with('/') || watch.route.starts_with("//") + || watch.route.bytes().any(|b| !b.is_ascii() || b.is_ascii_control() || b"\\%?#:".contains(&b)) + || watch.route.split('/').any(|part| part == "." || part == "..") { + return Err(invalid("invalid product-relative route")); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests; + +/// Product-call view tied to the verified execution rather than a mutable global +/// product-name lookup. Background callbacks use the resident service directly. +pub struct ReceivingExecution<'a> { + service: &'a ReceivingService, + authority: ReceivingAuthority, +} + +impl ReceivingExecution<'_> { + /// Read receiver state without accepting a stale execution's authority. + pub async fn status(&self) -> Result { + self.service.status_scoped(&self.authority.product_id, Some(&self.authority)).await + } + /// Atomically replace policy, rechecking immutable provenance after consent. + pub async fn replace(&self, expected_revision: u64, watches: Vec) -> Result { + self.service.replace_scoped(&self.authority.product_id, expected_revision, watches, Some(&self.authority)).await + } + /// Disable only the calling execution's current account scope. + pub async fn disable(&self, expected_revision: u64) -> Result { + self.service.disable_scoped(&self.authority.product_id, expected_revision, Some(&self.authority)).await + } + /// Record product-confirmed foreground/read evidence. + pub async fn receipt(&self, revision: u64, watch_id: String, event_id: String, kind: ReceivingReceiptKind) -> Result { + self.service.receipt_scoped(&self.authority.product_id, revision, watch_id, event_id, kind, Some(&self.authority)).await + } + /// Read durable events belonging to the verified execution. + pub async fn events(&self, after_sequence: u64) -> Result, Error> { + self.service.events_scoped(&self.authority.product_id, after_sequence, Some(&self.authority)).await + } + /// Acknowledge exactly one event under the same authority fence. + pub async fn acknowledge(&self, sequence: u64) -> Result<(), Error> { + self.service.acknowledge_scoped(&self.authority.product_id, sequence, Some(&self.authority)).await + } +} diff --git a/rust/crates/truapi/src/runtime/receiving/tests.rs b/rust/crates/truapi/src/runtime/receiving/tests.rs new file mode 100644 index 0000000000..6d8e012371 --- /dev/null +++ b/rust/crates/truapi/src/runtime/receiving/tests.rs @@ -0,0 +1,465 @@ +use super::*; +use crate::test_support::{StubPlatform, test_spawner}; +use ed25519_dalek::{Signer, SigningKey}; +use futures::executor::block_on; +use sha2::{Digest, Sha256}; + +const PRODUCT: &str = "receiver.paseo"; +fn authority() -> ReceivingAuthority { + ReceivingAuthority { product_id: PRODUCT.into(), account: "11".repeat(32), environment: "paseo".into(), + artifact: "22".repeat(32), genesis: "33".repeat(32), generation: 1, os_permission: true, transport_ready: true } +} +fn key() -> SigningKey { SigningKey::from_bytes(&[42; 32]) } +fn watch() -> ReceivingWatch { + ReceivingWatch { id: "inbox".into(), genesis: authority().genesis, channel: "44".repeat(32), + topics: vec!["55".repeat(32)], senders: vec![hex::encode(key().verifying_key().as_bytes())], + expires_at: now() + DAY, muted_until: 0, route: "/inbox".into() } +} +fn setup() -> (Arc, ReceivingService) { + let platform = Arc::new(StubPlatform::default()); + *platform.receiving_authority.lock() = Some(authority()); + platform.receiving_consent.store(true, Ordering::SeqCst); + let service = ReceivingService::new(platform.clone(), test_spawner()); + (platform, service) +} +// Independent minimal standard Gordian Envelope writer for the real verifier. +fn cbor(out: &mut Vec, major: u8, value: u64) { + if value < 24 { out.push(major << 5 | value as u8); } + else if value <= 255 { out.extend([major << 5 | 24, value as u8]); } + else if value <= 65535 { out.push(major << 5 | 25); out.extend((value as u16).to_be_bytes()); } + else { out.push(major << 5 | 26); out.extend((value as u32).to_be_bytes()); } +} +fn leaf(out: &mut Vec, major: u8, bytes: &[u8]) { + cbor(out, 6, 201); cbor(out, major, bytes.len() as u64); out.extend(bytes); +} +fn frame(event: u8, created_at: u64, topics: &[String]) -> Vec { + let watch = watch(); + let carrier = [1u8, 2, 3]; + let digest = hex::encode(Sha256::digest(carrier)); + let sender = hex::encode(key().verifying_key().as_bytes()); + let event_id = hex::encode([event; 32]); + let expires_at = created_at + 60_000; + let signed = serde_json::to_vec(&("truapi:notification:v1", 1, PRODUCT, &watch.genesis, + &watch.channel, topics, &event_id, created_at, expires_at, &digest, &sender)).unwrap(); + let header = serde_json::json!({ "v":1,"product":PRODUCT,"genesis":watch.genesis,"channel":watch.channel, + "topics":topics,"eventId":event_id,"createdAt":created_at,"expiresAt":expires_at, + "ciphertextDigest":digest,"senderKey":sender,"signature":hex::encode(key().sign(&signed).to_bytes()) }); + let header = serde_json::to_vec(&header).unwrap(); + let mut out = Vec::new(); + cbor(&mut out, 6, 200); cbor(&mut out, 4, 2); + leaf(&mut out, 2, &carrier); + cbor(&mut out, 5, 1); + leaf(&mut out, 3, b"truapiNotification"); leaf(&mut out, 3, &header); + out +} +async fn deliver(service: &ReceivingService, revision: u64, event: u8) -> Result, Error> { + let watch = watch(); + // Actual transport may add topics; signed topics must still cover the watch. + let topics = vec![watch.topics[0].clone(), "66".repeat(32)]; + service.ingest(PRODUCT, revision, watch.id, watch.genesis, watch.channel, + topics, frame(event, now(), &watch.topics)).await +} + +#[test] +fn missing_backend_is_unsupported_and_os_grant_is_not_consent() { + block_on(async { + let service = ReceivingService::new(Arc::new(StubPlatform::default()), test_spawner()); + assert!(!service.status(PRODUCT).await.unwrap().supported); + assert!(matches!(service.replace(PRODUCT, 0, vec![watch()]).await, Err(Error::Unsupported))); + let (platform, service) = setup(); + platform.receiving_consent.store(false, Ordering::SeqCst); + assert!(matches!(service.replace(PRODUCT, 0, vec![watch()]).await, Err(Error::PermissionDenied))); + assert!(!service.status(PRODUCT).await.unwrap().consent); + }); +} + +#[test] +fn registration_and_receipts_survive_all_product_executions_closing() { + block_on(async { + let (platform, service) = setup(); + let status = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap(); + let event = deliver(&service, status.revision, 1).await.unwrap().remove(0); + drop(service); + let restored = ReceivingService::new(platform, test_spawner()); + assert_eq!(restored.events(PRODUCT, 0).await.unwrap(), vec![event]); + assert!(deliver(&restored, status.revision, 1).await.unwrap().is_empty()); + assert_eq!(restored.pending().await.unwrap().len(), 1); + }); +} + +#[test] +fn foreground_and_read_receipts_win_before_display_reservation() { + block_on(async { + let (_, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let event = deliver(&service, revision, 2).await.unwrap().remove(0); + service.receipt(PRODUCT, revision, watch().id, event.event_id.clone(), ReceivingReceiptKind::Foreground).await.unwrap(); + assert!(service.prepare_display(PRODUCT, revision, event.event_id.clone()).await.unwrap().is_none()); + service.receipt(PRODUCT, revision, watch().id, event.event_id.clone(), ReceivingReceiptKind::Read).await.unwrap(); + assert!(service.validate_activation(PRODUCT, revision, event.event_id).await.unwrap().is_none()); + }); +} + +#[test] +fn display_reservation_and_activation_are_distinct_and_replay_safe() { + block_on(async { + let (_, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let event = deliver(&service, revision, 3).await.unwrap().remove(0); + assert!(service.prepare_display(PRODUCT, revision, event.event_id.clone()).await.unwrap().is_some()); + assert!(service.prepare_display(PRODUCT, revision, event.event_id.clone()).await.unwrap().is_none()); + service.confirm_display(PRODUCT, revision, event.event_id.clone()).await.unwrap(); + let preview = service.validate_activation(PRODUCT, revision, event.event_id.clone()).await.unwrap().unwrap(); + assert_eq!(preview.sequence, 0); + let activation = service.activate(PRODUCT, revision, event.event_id.clone()).await.unwrap().unwrap(); + assert_eq!(activation.kind, ReceivingEventKind::Activation); + assert!(service.activate(PRODUCT, revision, event.event_id).await.unwrap().is_none()); + service.acknowledge(PRODUCT, activation.sequence).await.unwrap(); + assert!(service.events(PRODUCT, 0).await.unwrap().iter().all(|e| e.sequence != activation.sequence)); + }); +} + +#[test] +fn message_receipts_do_not_acknowledge_a_queued_user_activation() { + block_on(async { + for kind in [ReceivingReceiptKind::Foreground, ReceivingReceiptKind::Displayed, ReceivingReceiptKind::Read] { + let (_, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let event = deliver(&service, revision, 3).await.unwrap().remove(0); + service.prepare_display(PRODUCT, revision, event.event_id.clone()).await.unwrap().unwrap(); + service.confirm_display(PRODUCT, revision, event.event_id.clone()).await.unwrap(); + let activation = service.activate(PRODUCT, revision, event.event_id.clone()).await.unwrap().unwrap(); + service.receipt(PRODUCT, revision, watch().id, event.event_id, kind).await.unwrap(); + assert_eq!(service.events(PRODUCT, 0).await.unwrap(), vec![activation.clone()]); + service.acknowledge(PRODUCT, activation.sequence).await.unwrap(); + assert!(service.events(PRODUCT, 0).await.unwrap().is_empty()); + } + }); +} + +#[test] +fn replacement_is_atomic_and_stale_sync_cannot_acknowledge_revoke() { + block_on(async { + let (_, service) = setup(); + let first = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap(); + let mut invalid_watch = watch(); invalid_watch.route = "//evil.invalid".into(); + assert!(service.replace(PRODUCT, first.revision, vec![invalid_watch]).await.is_err()); + assert_eq!(service.status(PRODUCT).await.unwrap().revision, first.revision); + let disabled = service.disable(PRODUCT, first.revision).await.unwrap(); + assert!(!disabled.enabled); + assert!(!service.synchronized(PRODUCT, first.revision).await.unwrap()); + assert!(service.status(PRODUCT).await.unwrap().sync_pending); + assert!(matches!(deliver(&service, first.revision, 4).await, Err(Error::Conflict))); + }); +} + +#[test] +fn pending_confirmation_cannot_resurrect_revoked_first_registration() { + block_on(async { + let (platform, service) = setup(); + let (release, gate) = futures::channel::oneshot::channel(); + *platform.receiving_consent_gate.lock() = Some(gate); + let replace = service.replace(PRODUCT, 0, vec![watch()]); + let revoke = async { + // join polls replace first, which reaches and parks at consent. + service.revoke(PRODUCT).await.unwrap(); + release.send(()).unwrap(); + }; + let (result, ()) = futures::join!(replace, revoke); + assert!(matches!(result, Err(Error::Conflict))); + assert!(!service.status(PRODUCT).await.unwrap().enabled); + }); +} + +#[test] +fn failed_durable_write_never_reports_enrollment_or_display_success() { + block_on(async { + let (platform, service) = setup(); + platform.receiving_write_failure.store(true, Ordering::SeqCst); + assert!(matches!(service.replace(PRODUCT, 0, vec![watch()]).await, Err(Error::Storage { .. }))); + assert_eq!(service.status(PRODUCT).await.unwrap().revision, 0); + platform.receiving_write_failure.store(false, Ordering::SeqCst); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let event = deliver(&service, revision, 5).await.unwrap().remove(0); + platform.receiving_write_failure.store(true, Ordering::SeqCst); + assert!(service.prepare_display(PRODUCT, revision, event.event_id.clone()).await.is_err()); + platform.receiving_write_failure.store(false, Ordering::SeqCst); + assert!(service.prepare_display(PRODUCT, revision, event.event_id).await.unwrap().is_some()); + }); +} + +#[test] +fn account_artifact_environment_generation_and_os_changes_revalidate() { + block_on(async { + for field in 0..5 { + let (platform, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let mut next = authority(); + match field { 0 => next.account = "77".repeat(32), 1 => next.artifact = "88".repeat(32), + 2 => next.environment = "polkadot".into(), 3 => next.generation += 1, _ => next.os_permission = false } + *platform.receiving_authority.lock() = Some(next); + assert!(!service.status(PRODUCT).await.unwrap().enabled); + assert!(deliver(&service, revision, 6).await.is_err()); + assert!(service.pending().await.unwrap().iter().all(|r| !r.enabled)); + } + }); +} + +#[test] +fn sender_channel_source_and_signature_cannot_be_forged() { + block_on(async { + let (_, service) = setup(); + let watch = watch(); + let revision = service.replace(PRODUCT, 0, vec![watch.clone()]).await.unwrap().revision; + let body = frame(7, now(), &watch.topics); + assert!(service.ingest(PRODUCT, revision, watch.id.clone(), watch.genesis.clone(), "99".repeat(32), watch.topics.clone(), body.clone()).await.is_err()); + assert!(service.ingest(PRODUCT, revision, watch.id.clone(), "99".repeat(32), watch.channel.clone(), watch.topics.clone(), body.clone()).await.is_err()); + let mut altered = body; + // Subject byte tamper leaves the valid signature over the old digest intact. + let index = altered.windows(4).position(|w| w == [0x43, 1, 2, 3]).unwrap(); + altered[index + 1] ^= 1; + assert!(service.ingest(PRODUCT, revision, watch.id, watch.genesis, watch.channel, watch.topics, altered).await.unwrap().is_empty()); + }); +} + +#[test] +fn capacity_rejection_preserves_prior_state_and_live_replay_entries() { + block_on(async { + let (_, service) = setup(); + let first = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap(); + let too_many: Vec<_> = (0..257).map(|i| { let mut w = watch(); w.id = format!("w{i}"); w }).collect(); + assert!(matches!(service.replace(PRODUCT, first.revision, too_many).await, Err(Error::Capacity))); + for event in 10..14 { assert_eq!(deliver(&service, first.revision, event).await.unwrap().len(), 1); } + assert!(deliver(&service, first.revision, 14).await.unwrap().is_empty()); + assert!(deliver(&service, first.revision, 10).await.unwrap().is_empty()); + assert_eq!(service.status(PRODUCT).await.unwrap().revision, first.revision); + }); +} + +#[test] +fn mute_narrowing_does_not_prompt_and_unmute_does_not_replay_history() { + block_on(async { + let (platform, service) = setup(); + let mut watch = watch(); + let first = service.replace(PRODUCT, 0, vec![watch.clone()]).await.unwrap(); + watch.muted_until = u64::MAX; + let muted = service.replace(PRODUCT, first.revision, vec![watch.clone()]).await.unwrap(); + assert!(deliver(&service, muted.revision, 20).await.unwrap().is_empty()); + assert_eq!(platform.receiving_prompts.load(Ordering::SeqCst), 1); + watch.muted_until = 0; + let unmuted = service.replace(PRODUCT, muted.revision, vec![watch.clone()]).await.unwrap(); + assert!(service.ingest(PRODUCT, unmuted.revision, watch.id, watch.genesis, watch.channel, + watch.topics.clone(), frame(21, now() - 10_000, &watch.topics)).await.unwrap().is_empty()); + }); +} + +#[test] +fn durable_hourly_budget_is_checked_at_display_not_enrollment() { + block_on(async { + let (_, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let event = deliver(&service, revision, 22).await.unwrap().remove(0); + let mut ledger = service.load().await.unwrap(); + ledger.records[0].display_attempts = vec![now(); 60]; + service.save(&ledger).await.unwrap(); + assert!(service.prepare_display(PRODUCT, revision, event.event_id).await.unwrap().is_none()); + assert!(service.status(PRODUCT).await.unwrap().enabled); + }); +} + +#[test] +fn expired_or_missing_statement_expiry_never_qualifies() { + use crate::host_logic::statement_store::{StatementField, StatementProof}; + use parity_scale_codec::Encode; + block_on(async { + let (_, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let fields = vec![ + StatementField::Proof(StatementProof::Sr25519 { signature: [0; 64], signer: [0; 32] }), + StatementField::Expiry(((now() / 1000) - 10) << 32), + StatementField::Channel([0x44; 32]), + StatementField::Topic1([0x55; 32]), + StatementField::Data(frame(30, now(), &watch().topics)), + ]; + assert!(service.ingest_statement(PRODUCT, revision, watch().id, watch().genesis, fields.encode()).await.unwrap().is_empty()); + let without_expiry: Vec<_> = fields.into_iter().filter(|f| !matches!(f, StatementField::Expiry(_))).collect(); + assert!(service.ingest_statement(PRODUCT, revision, watch().id, watch().genesis, without_expiry.encode()).await.is_err()); + }); +} + +#[test] +fn independent_authenticated_backfill_candidates_have_separate_receipts() { + block_on(async { + let (_, service) = setup(); + let watch = watch(); + let revision = service.replace(PRODUCT, 0, vec![watch.clone()]).await.unwrap().revision; + let first = frame(31, now(), &watch.topics); + let second = frame(32, now(), &watch.topics); + let mut carrier = Vec::new(); + cbor(&mut carrier, 6, 200); + cbor(&mut carrier, 4, 2); + carrier.extend(&first[2..]); + cbor(&mut carrier, 5, 1); + leaf(&mut carrier, 3, b"history"); + carrier.extend(&second[2..]); + let accepted = service.ingest(PRODUCT, revision, watch.id.clone(), watch.genesis.clone(), watch.channel.clone(), + watch.topics.clone(), carrier.clone()).await.unwrap(); + assert_eq!(accepted.len(), 2); + assert_ne!(accepted[0].event_id, accepted[1].event_id); + assert!(service.ingest(PRODUCT, revision, watch.id, watch.genesis, watch.channel, watch.topics, + carrier).await.unwrap().is_empty()); + }); +} + +#[test] +fn provider_confirmed_display_is_not_a_local_display_authorization() { + block_on(async { + let (_, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let event = deliver(&service, revision, 33).await.unwrap().remove(0); + service.confirm_display(PRODUCT, revision, event.event_id.clone()).await.unwrap(); + assert!(service.prepare_display(PRODUCT, revision, event.event_id.clone()).await.unwrap().is_none()); + assert!(service.validate_activation(PRODUCT, revision, event.event_id).await.unwrap().is_some()); + }); +} + +#[test] +fn stale_product_execution_cannot_enroll_under_a_new_verified_artifact() { + block_on(async { + let (platform, service) = setup(); + let old_execution = service.for_execution(authority()); + let mut replacement = authority(); + replacement.artifact = "aa".repeat(32); + *platform.receiving_authority.lock() = Some(replacement.clone()); + assert!(matches!(old_execution.status().await, Err(Error::PermissionDenied))); + assert!(matches!(old_execution.replace(0, vec![watch()]).await, Err(Error::PermissionDenied))); + assert_eq!(platform.receiving_prompts.load(Ordering::SeqCst), 0); + assert!(service.for_execution(replacement).replace(0, vec![watch()]).await.unwrap().enabled); + }); +} + +#[test] +fn execution_provenance_is_rechecked_after_receiving_consent() { + block_on(async { + let (platform, service) = setup(); + let execution = service.for_execution(authority()); + let (release, gate) = futures::channel::oneshot::channel(); + *platform.receiving_consent_gate.lock() = Some(gate); + let enroll = execution.replace(0, vec![watch()]); + let switch = async { + let mut replacement = authority(); + replacement.generation += 1; + *platform.receiving_authority.lock() = Some(replacement); + release.send(()).unwrap(); + }; + let (result, ()) = futures::join!(enroll, switch); + assert!(matches!(result, Err(Error::PermissionDenied))); + assert!(!service.status(PRODUCT).await.unwrap().enabled); + }); +} + +#[test] +fn explicit_logout_revokes_all_and_does_not_await_transport() { + block_on(async { + let (_, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + service.revoke_all().await.unwrap(); + assert!(!service.status(PRODUCT).await.unwrap().enabled); + assert!(!service.synchronized(PRODUCT, revision).await.unwrap()); + assert!(service.pending().await.unwrap().iter().all(|r| !r.enabled && r.sync_pending)); + }); +} + +#[test] +fn foreground_receipt_reports_actual_display_not_a_reserved_claim() { + block_on(async { + let (platform, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let event = deliver(&service, revision, 34).await.unwrap().remove(0); + service.prepare_display(PRODUCT, revision, event.event_id.clone()).await.unwrap().unwrap(); + let outcome = service.receipt(PRODUCT, revision, watch().id, event.event_id.clone(), ReceivingReceiptKind::Foreground).await.unwrap(); + assert!(!outcome.displayed); + assert!(outcome.display_pending); + drop(service); + let restored = ReceivingService::new(platform, test_spawner()); + let unknown_after_restart = restored.receipt(PRODUCT, revision, watch().id, event.event_id.clone(), ReceivingReceiptKind::Foreground).await.unwrap(); + assert!(!unknown_after_restart.displayed); + assert!(unknown_after_restart.display_pending); + restored.confirm_display(PRODUCT, revision, event.event_id.clone()).await.unwrap(); + let confirmed = restored.receipt(PRODUCT, revision, watch().id, event.event_id, ReceivingReceiptKind::Foreground).await.unwrap(); + assert!(confirmed.displayed); + assert!(!confirmed.display_pending); + }); +} + +#[test] +fn known_failed_display_releases_claim_without_claiming_os_success() { + block_on(async { + let (_, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let event = deliver(&service, revision, 35).await.unwrap().remove(0); + service.prepare_display(PRODUCT, revision, event.event_id.clone()).await.unwrap().unwrap(); + service.receipt(PRODUCT, revision, watch().id, event.event_id.clone(), ReceivingReceiptKind::Foreground).await.unwrap(); + service.cancel_display(PRODUCT, revision, event.event_id.clone()).await.unwrap(); + let fallback = service.receipt(PRODUCT, revision, watch().id, event.event_id.clone(), ReceivingReceiptKind::Foreground).await.unwrap(); + assert!(!fallback.displayed && !fallback.display_pending); + let shown = service.receipt(PRODUCT, revision, watch().id, event.event_id.clone(), ReceivingReceiptKind::Displayed).await.unwrap(); + assert!(shown.displayed && !shown.display_pending); + service.cancel_display(PRODUCT, revision, event.event_id.clone()).await.unwrap(); + let repeated = service.receipt(PRODUCT, revision, watch().id, event.event_id, ReceivingReceiptKind::Foreground).await.unwrap(); + assert!(repeated.displayed && !repeated.display_pending); + }); +} + +#[test] +fn foreground_before_host_ingest_suppresses_host_without_faking_display() { + block_on(async { + let (_, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let outcome = service.receipt(PRODUCT, revision, watch().id, hex::encode([36u8; 32]), ReceivingReceiptKind::Foreground).await.unwrap(); + assert!(!outcome.displayed && !outcome.display_pending); + assert!(deliver(&service, revision, 36).await.unwrap().is_empty()); + }); +} + +#[test] +fn token_rotation_preserves_accepted_events_and_fences_old_acknowledgements() { + block_on(async { + let (_, service) = setup(); + let old = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let accepted = deliver(&service, old, 37).await.unwrap().remove(0); + service.mark_transport_changed(PRODUCT).await.unwrap(); + let current = service.status(PRODUCT).await.unwrap().revision; + assert!(current > old); + assert!(!service.synchronized(PRODUCT, old).await.unwrap()); + let events = service.events(PRODUCT, 0).await.unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_id, accepted.event_id); + assert_eq!(events[0].revision, current); + assert!(deliver(&service, current, 37).await.unwrap().is_empty()); + assert!(service.prepare_display(PRODUCT, current, accepted.event_id).await.unwrap().is_some()); + }); +} + +#[test] +fn new_account_sync_cannot_acknowledge_old_account_revocation() { + block_on(async { + let (platform, service) = setup(); + service.replace(PRODUCT, 0, vec![watch()]).await.unwrap(); + let mut second = authority(); + second.account = "bb".repeat(32); + second.generation += 1; + *platform.receiving_authority.lock() = Some(second); + let active = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap(); + let pending = service.pending().await.unwrap(); + let retired = pending.iter().find(|r| !r.enabled).unwrap(); + assert_ne!(retired.revision, active.revision); + assert!(service.synchronized(PRODUCT, active.revision).await.unwrap()); + let pending = service.pending().await.unwrap(); + assert!(pending.iter().find(|r| !r.enabled).unwrap().sync_pending); + assert!(!pending.iter().find(|r| r.enabled).unwrap().sync_pending); + service.revoke_all().await.unwrap(); + let revoked = service.pending().await.unwrap(); + assert_ne!(revoked[0].revision, revoked[1].revision); + }); +} diff --git a/rust/crates/truapi/src/runtime/services.rs b/rust/crates/truapi/src/runtime/services.rs index 918042cbc0..20465f6800 100644 --- a/rust/crates/truapi/src/runtime/services.rs +++ b/rust/crates/truapi/src/runtime/services.rs @@ -34,6 +34,8 @@ const STATEMENT_CACHE_MAX_ENTRIES: usize = 64; pub struct RuntimeServices { /// Host platform backing all syscalls. pub platform: Arc, + /// Durable receiving survives all product connections closing. + pub receiving: Arc, /// Host identity reported to products via `System::host_info`. pub host_info: HostInfo, /// Host chat adapter, when the host serves the Chat capability. `None` @@ -154,6 +156,10 @@ impl RuntimeServices { StatementStoreRpc::new(platform.clone(), people_chain_genesis_hash, spawner.clone()); let bulletin = BulletinRpc::new(chain.clone(), bulletin_chain_genesis_hash); Arc::new(Self { + receiving: Arc::new(crate::runtime::receiving::ReceivingService::new( + platform.clone(), + spawner.clone(), + )), platform, host_info, chat_platform, diff --git a/rust/crates/truapi/src/test_support.rs b/rust/crates/truapi/src/test_support.rs index 1cb84368b9..5e35cf8729 100644 --- a/rust/crates/truapi/src/test_support.rs +++ b/rust/crates/truapi/src/test_support.rs @@ -232,6 +232,11 @@ pub struct StubPlatform { pub notification_id: u32, pub pushed_notifications: Arc>>, pub cancelled_notifications: Arc>>, + pub receiving_authority: parking_lot::Mutex>, + pub receiving_consent: AtomicBool, + pub receiving_prompts: AtomicUsize, + pub receiving_consent_gate: parking_lot::Mutex>>, + pub receiving_write_failure: AtomicBool, pub sent_rpc: Arc>>, pub rpc_responses: Vec, /// Responses keyed by JSON-RPC method, answered as each request arrives with @@ -1188,6 +1193,9 @@ impl PlatformCoreStorage for StubPlatform { reason: "injected core write failure".into(), }); } + if key == CoreStorageKey::NotificationReceiving && self.receiving_write_failure.load(Ordering::SeqCst) { + return Err(v01::GenericError { reason: "receiving storage unavailable".to_owned() }); + } if let CoreStorageKey::AuthSession = key { self.session_writes .lock() @@ -1255,6 +1263,17 @@ impl PlatformNavigation for StubPlatform { #[crate::platform::async_trait] impl PlatformNotifications for StubPlatform { + async fn receiver_authority(&self, product_id: &str) -> Result, v01::GenericError> { + Ok(self.receiving_authority.lock().clone().filter(|a| a.product_id == product_id)) + } + + async fn receiver_consent(&self, _authority: crate::platform::ReceivingAuthority, _watches: Vec) -> Result { + self.receiving_prompts.fetch_add(1, Ordering::SeqCst); + let gate = self.receiving_consent_gate.lock().take(); + if let Some(gate) = gate { let _ = gate.await; } + Ok(self.receiving_consent.load(Ordering::SeqCst)) + } + async fn push_notification( &self, notification: v01::HostPushNotificationRequest, diff --git a/rust/crates/truapi/src/v01/notifications.rs b/rust/crates/truapi/src/v01/notifications.rs index edb17600c4..9245711c2e 100644 --- a/rust/crates/truapi/src/v01/notifications.rs +++ b/rust/crates/truapi/src/v01/notifications.rs @@ -1,4 +1,5 @@ use alloc::string::String; +use alloc::vec::Vec; use parity_scale_codec::{Decode, Encode}; /// Push notification payload. @@ -48,3 +49,171 @@ pub struct HostPushNotificationCancelRequest { /// The notification identifier returned by [`HostPushNotificationResponse`]. pub id: u32, } + +/// An authenticated source filter enrolled under host-owned receiving consent. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "runtime", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Record))] +pub struct ReceivingWatch { + /// Product-local opaque watch identifier. + pub id: String, + /// Canonical lowercase 32-byte chain genesis hash. + pub genesis: String, + /// Exact source channel, encoded as a canonical lowercase 32-byte hash. + pub channel: String, + /// One to four selected topics; every topic must occur in the signed header. + pub topics: Vec, + /// Approved Ed25519 public keys in canonical lowercase hex. + pub senders: Vec, + /// Expiration in Unix milliseconds, bounded to a JavaScript safe integer. + pub expires_at: u64, + /// Unix milliseconds before which delivery is muted; `u64::MAX` means forever. + pub muted_until: u64, + /// Product-relative activation route, retained locally and never relayed. + pub route: String, +} + +/// Receiving support, consent and durable synchronization state. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "runtime", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Record))] +pub struct HostNotificationReceiverStatus { + /// Whether this host supplies trusted receiving authority. + pub supported: bool, + /// Current OS permission for visible notifications. + pub os_permission: bool, + /// Whether current authority and watches have receiving consent. + pub consent: bool, + /// Whether receiving is locally enabled. + pub enabled: bool, + /// Compare-and-swap token for the durable registration. + pub revision: u64, + /// Whether the transport still needs to synchronize this revision. + pub sync_pending: bool, + /// Whether the selected platform transport is ready. + pub transport_ready: bool, +} + +/// Confirmed application handling, independent of transport acknowledgement. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "runtime", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Enum))] +pub enum ReceivingReceiptKind { + /// The application handled the event in the foreground; not proof of OS display. + Foreground, + /// The user read the event. + Read, + /// The product's OS notification API successfully displayed the event. + Displayed, +} + +/// Actual display outcome after recording a receipt, distinct from enrollment ACKs. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "runtime", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Record))] +pub struct HostNotificationReceiptResult { + /// An OS display was positively confirmed by the host or product. + pub displayed: bool, + /// A display is reserved but unconfirmed; do not start a competing fallback. + /// Explicit failure cancels the reservation; unknown outcomes remain pending until expiry. + pub display_pending: bool, +} + +/// Why an authenticated receiving event was queued. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "runtime", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Enum))] +pub enum ReceivingEventKind { + /// An authenticated event arrived without focusing the product. + Delivery, + /// The user activated a locally accepted notification. + Activation, +} + +/// A bounded durable event containing opaque identifiers, never plaintext. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "runtime", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Record))] +pub struct ReceivingEvent { + /// Durable sequence used for polling and acknowledgement. + pub sequence: u64, + /// Registration revision that accepted the event. + pub revision: u64, + /// Product-local watch identifier. + pub watch_id: String, + /// Authenticated event identifier. + pub event_id: String, + /// Delivery or user activation. + pub kind: ReceivingEventKind, + /// Locally enrolled product-relative route. + pub route: String, + /// Expiration in Unix milliseconds. + pub expires_at: u64, +} + +/// Receiving policy, persistence or support failure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostNotificationReceivingError { + /// This host has no receiving adapter. + Unsupported, + /// Notification permission or scoped receiving consent was denied. + PermissionDenied, + /// The request violates the receiving schema or authenticated policy. + InvalidRequest { + /// Human-readable reason. + reason: String, + }, + /// The registration revision or trusted authority changed. + Conflict, + /// The bounded receiving store or watch budget is full. + Capacity, + /// Durable persistence failed. + Storage { + /// Human-readable reason. + reason: String, + }, +} + +/// Atomically replace the current authority's complete watch set. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostNotificationReplaceReceiverRequest { + /// Revision observed by the caller. + pub expected_revision: u64, + /// Complete replacement, at most 256 watches and 10,000 senders total. + /// Each watch permits at most 1,000 senders. + pub watches: Vec, +} + +/// Disable locally without awaiting transport revocation. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostNotificationDisableReceiverRequest { + /// Revision observed by the caller. + pub expected_revision: u64, +} + +/// Record confirmed foreground handling or reading. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostNotificationRecordReceiptRequest { + /// Revision that accepted the event. + pub revision: u64, + /// Product-local watch identifier. + pub watch_id: String, + /// Authenticated event identifier. + pub event_id: String, + /// Confirmed handling kind. + pub kind: ReceivingReceiptKind, +} + +/// Poll durable events without creating a UI-lifetime subscription. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostNotificationReceiverEventsRequest { + /// Return only events after this sequence. + pub after_sequence: u64, +} + +/// Acknowledge a durable event after application handling. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostNotificationAcknowledgeReceiverEventRequest { + /// Durable event sequence. + pub sequence: u64, +} diff --git a/rust/crates/truapi/src/versioned/notifications.rs b/rust/crates/truapi/src/versioned/notifications.rs index 3a8b7c5e53..7921af576c 100644 --- a/rust/crates/truapi/src/versioned/notifications.rs +++ b/rust/crates/truapi/src/versioned/notifications.rs @@ -1,5 +1,6 @@ //! Versioned wrappers for [`Notifications`](crate::api::Notifications) methods. +use alloc::vec::Vec; use crate::v01; truapi_macros::versioned_type! { @@ -9,4 +10,17 @@ truapi_macros::versioned_type! { pub enum HostPushNotificationCancelRequest { V1 => v01::HostPushNotificationCancelRequest } pub enum HostPushNotificationCancelResponse { V1 } pub enum HostPushNotificationCancelError { V1 => v01::GenericError } + pub enum HostNotificationReceiverStatusRequest { V1 } + pub enum HostNotificationReceiverStatusResponse { V1 => v01::HostNotificationReceiverStatus } + pub enum HostNotificationReplaceReceiverRequest { V1 => v01::HostNotificationReplaceReceiverRequest } + pub enum HostNotificationReplaceReceiverResponse { V1 => v01::HostNotificationReceiverStatus } + pub enum HostNotificationDisableReceiverRequest { V1 => v01::HostNotificationDisableReceiverRequest } + pub enum HostNotificationDisableReceiverResponse { V1 => v01::HostNotificationReceiverStatus } + pub enum HostNotificationRecordReceiptRequest { V1 => v01::HostNotificationRecordReceiptRequest } + pub enum HostNotificationRecordReceiptResponse { V1 => v01::HostNotificationReceiptResult } + pub enum HostNotificationReceiverEventsRequest { V1 => v01::HostNotificationReceiverEventsRequest } + pub enum HostNotificationReceiverEventsResponse { V1 => Vec } + pub enum HostNotificationAcknowledgeReceiverEventRequest { V1 => v01::HostNotificationAcknowledgeReceiverEventRequest } + pub enum HostNotificationAcknowledgeReceiverEventResponse { V1 } + pub enum HostNotificationReceivingError { V1 => v01::HostNotificationReceivingError } } diff --git a/rust/crates/truapi/src/wasm.rs b/rust/crates/truapi/src/wasm.rs index aaa3b0410c..59737760bc 100644 --- a/rust/crates/truapi/src/wasm.rs +++ b/rust/crates/truapi/src/wasm.rs @@ -44,6 +44,9 @@ use crate::{ }; mod generated_bridge; +mod receiving; + +pub use receiving::WasmNotificationReceiver; use generated_bridge::JsBridge; @@ -569,7 +572,7 @@ fn get_optional_function(callbacks: &JsValue, name: &str) -> Result Function { .unchecked_into() } +fn absent_optional_callback() -> Function { + Closure:: JsValue>::new(|| JsValue::UNDEFINED) + .into_js_value() + .unchecked_into() +} + /// Stand-in for a callback of an optional capability the host left out. The /// core only holds an adapter for a capability the bridge reports as present, /// so this is never invoked; it throws rather than returning a value the @@ -1073,6 +1082,10 @@ fn install_worker_demand_observer( Ok(()) } +fn receiving_error_to_js(error: crate::latest::HostNotificationReceivingError) -> JsValue { + js_sys::Error::new(&format!("background receiving: {error:?}")).into() +} + /// JS-callable handle to a long-lived pairing-host runtime shared by product /// cores. #[wasm_bindgen] @@ -1082,6 +1095,83 @@ pub struct WasmPairingHostRuntime { #[wasm_bindgen] impl WasmPairingHostRuntime { + /// All durable registrations as SCALE `Vec`. + #[wasm_bindgen(js_name = receivingPending)] + pub async fn receiving_pending(&self) -> Result, JsValue> { + self.runtime.receiving().pending().await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Acknowledge the exact durable local revision synchronized by transport. + #[wasm_bindgen(js_name = receivingSynchronized)] + pub async fn receiving_synchronized(&self, product_id: String, revision: u64) -> Result { + self.runtime.receiving().synchronized(&product_id, revision).await.map_err(receiving_error_to_js) + } + + /// Verify source chain/channel/topics and all candidates; returns SCALE `Vec`. + #[wasm_bindgen(js_name = receivingIngest)] + pub async fn receiving_ingest( + &self, product_id: String, revision: u64, watch_id: String, + actual_genesis: String, actual_channel: String, actual_topics: Vec, frame: Vec, + ) -> Result, JsValue> { + self.runtime.receiving().ingest(&product_id, revision, watch_id, actual_genesis, actual_channel, actual_topics, frame) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Authenticate a raw SCALE statement; returns SCALE `Vec`. + #[wasm_bindgen(js_name = receivingIngestStatement)] + pub async fn receiving_ingest_statement( + &self, product_id: String, revision: u64, watch_id: String, + actual_genesis: String, statement: Vec, + ) -> Result, JsValue> { + self.runtime.receiving().ingest_statement(&product_id, revision, watch_id, actual_genesis, statement) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Reserve display after grace and revalidation; returns SCALE `Option`. + #[wasm_bindgen(js_name = receivingPrepareDisplay)] + pub async fn receiving_prepare_display(&self, product_id: String, revision: u64, event_id: String) -> Result, JsValue> { + self.runtime.receiving().prepare_display(&product_id, revision, event_id) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Validate a click without enqueueing activation; returns SCALE `Option`. + #[wasm_bindgen(js_name = receivingValidateActivation)] + pub async fn receiving_validate_activation(&self, product_id: String, revision: u64, event_id: String) -> Result, JsValue> { + self.runtime.receiving().validate_activation(&product_id, revision, event_id) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Confirm actual platform display, not ingestion or transport acknowledgement. + #[wasm_bindgen(js_name = receivingConfirmDisplay)] + pub async fn receiving_confirm_display(&self, product_id: String, revision: u64, event_id: String) -> Result<(), JsValue> { + self.runtime.receiving().confirm_display(&product_id, revision, event_id).await.map_err(receiving_error_to_js) + } + + /// Clear a reservation only after explicit platform display failure. + #[wasm_bindgen(js_name = receivingCancelDisplay)] + pub async fn receiving_cancel_display(&self, product_id: String, revision: u64, event_id: String) -> Result<(), JsValue> { + self.runtime.receiving().cancel_display(&product_id, revision, event_id).await.map_err(receiving_error_to_js) + } + + /// Resolve a click under current authority; returns SCALE `Option`. + #[wasm_bindgen(js_name = receivingActivate)] + pub async fn receiving_activate(&self, product_id: String, revision: u64, event_id: String) -> Result, JsValue> { + self.runtime.receiving().activate(&product_id, revision, event_id) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Revoke locally without waiting for remote transport. + #[wasm_bindgen(js_name = receivingRevoke)] + pub async fn receiving_revoke(&self, product_id: String) -> Result<(), JsValue> { + self.runtime.receiving().revoke(&product_id).await.map_err(receiving_error_to_js) + } + + /// Queue synchronization after durable provider-token rotation. + #[wasm_bindgen(js_name = receivingMarkTransportChanged)] + pub async fn receiving_mark_transport_changed(&self, product_id: String) -> Result<(), JsValue> { + self.runtime.receiving().mark_transport_changed(&product_id).await.map_err(receiving_error_to_js) + } + /// Build a shared runtime from host-level platform callbacks and host config. #[wasm_bindgen(constructor)] pub fn new( @@ -1387,6 +1477,83 @@ pub struct WasmSigningHostRuntime { #[cfg(feature = "wasm-signing-host")] #[wasm_bindgen] impl WasmSigningHostRuntime { + /// All durable registrations as SCALE `Vec`. + #[wasm_bindgen(js_name = receivingPending)] + pub async fn receiving_pending(&self) -> Result, JsValue> { + self.runtime.receiving().pending().await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Acknowledge the exact durable local revision synchronized by transport. + #[wasm_bindgen(js_name = receivingSynchronized)] + pub async fn receiving_synchronized(&self, product_id: String, revision: u64) -> Result { + self.runtime.receiving().synchronized(&product_id, revision).await.map_err(receiving_error_to_js) + } + + /// Verify source chain/channel/topics and all candidates; returns SCALE `Vec`. + #[wasm_bindgen(js_name = receivingIngest)] + pub async fn receiving_ingest( + &self, product_id: String, revision: u64, watch_id: String, + actual_genesis: String, actual_channel: String, actual_topics: Vec, frame: Vec, + ) -> Result, JsValue> { + self.runtime.receiving().ingest(&product_id, revision, watch_id, actual_genesis, actual_channel, actual_topics, frame) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Authenticate a raw SCALE statement; returns SCALE `Vec`. + #[wasm_bindgen(js_name = receivingIngestStatement)] + pub async fn receiving_ingest_statement( + &self, product_id: String, revision: u64, watch_id: String, + actual_genesis: String, statement: Vec, + ) -> Result, JsValue> { + self.runtime.receiving().ingest_statement(&product_id, revision, watch_id, actual_genesis, statement) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Reserve display after grace and revalidation; returns SCALE `Option`. + #[wasm_bindgen(js_name = receivingPrepareDisplay)] + pub async fn receiving_prepare_display(&self, product_id: String, revision: u64, event_id: String) -> Result, JsValue> { + self.runtime.receiving().prepare_display(&product_id, revision, event_id) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Validate a click without enqueueing activation; returns SCALE `Option`. + #[wasm_bindgen(js_name = receivingValidateActivation)] + pub async fn receiving_validate_activation(&self, product_id: String, revision: u64, event_id: String) -> Result, JsValue> { + self.runtime.receiving().validate_activation(&product_id, revision, event_id) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Confirm actual platform display, not ingestion or transport acknowledgement. + #[wasm_bindgen(js_name = receivingConfirmDisplay)] + pub async fn receiving_confirm_display(&self, product_id: String, revision: u64, event_id: String) -> Result<(), JsValue> { + self.runtime.receiving().confirm_display(&product_id, revision, event_id).await.map_err(receiving_error_to_js) + } + + /// Clear a reservation only after explicit platform display failure. + #[wasm_bindgen(js_name = receivingCancelDisplay)] + pub async fn receiving_cancel_display(&self, product_id: String, revision: u64, event_id: String) -> Result<(), JsValue> { + self.runtime.receiving().cancel_display(&product_id, revision, event_id).await.map_err(receiving_error_to_js) + } + + /// Resolve a click under current authority; returns SCALE `Option`. + #[wasm_bindgen(js_name = receivingActivate)] + pub async fn receiving_activate(&self, product_id: String, revision: u64, event_id: String) -> Result, JsValue> { + self.runtime.receiving().activate(&product_id, revision, event_id) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Revoke locally without waiting for remote transport. + #[wasm_bindgen(js_name = receivingRevoke)] + pub async fn receiving_revoke(&self, product_id: String) -> Result<(), JsValue> { + self.runtime.receiving().revoke(&product_id).await.map_err(receiving_error_to_js) + } + + /// Queue synchronization after durable provider-token rotation. + #[wasm_bindgen(js_name = receivingMarkTransportChanged)] + pub async fn receiving_mark_transport_changed(&self, product_id: String) -> Result<(), JsValue> { + self.runtime.receiving().mark_transport_changed(&product_id).await.map_err(receiving_error_to_js) + } + /// Answer resource allocation as granted without performing it. /// /// A test host serves suites that exercise allowance-dependent product diff --git a/rust/crates/truapi/src/wasm/receiving.rs b/rust/crates/truapi/src/wasm/receiving.rs new file mode 100644 index 0000000000..d28abfd79d --- /dev/null +++ b/rust/crates/truapi/src/wasm/receiving.rs @@ -0,0 +1,214 @@ +//! Wallet-free receiving owner for a host-origin service worker. + +use std::sync::Arc; +use js_sys::{Function, Uint8Array}; +use parity_scale_codec::{Decode, Encode}; +use send_wrapper::SendWrapper; +use wasm_bindgen::prelude::*; +use crate::latest::{self, HostNotificationReceivingError as Error}; +use crate::platform::ReceivingAuthority; +use crate::runtime::receiving::{ReceivingBackend, ReceivingService}; +use super::{get_function, get_optional_function, invoke_optional_bytes_return, invoke_bool, invoke_unit, generic, receiving_error_to_js}; + +struct JsReceivingBackend { + authority: SendWrapper, + consent: SendWrapper, + changed: SendWrapper, + load: SendWrapper, + save: SendWrapper, +} + +#[crate::platform::async_trait] +impl ReceivingBackend for JsReceivingBackend { + async fn receiver_authority(&self, product: &str) -> Result, latest::GenericError> { + let bytes = invoke_optional_bytes_return( + &self.authority, vec![JsValue::from_str(product)], + "receiverAuthority must return SCALE ReceivingAuthority or undefined", + ).await.map_err(generic)?; + bytes.map(|bytes| decode_exact(&bytes).map_err(generic)).transpose() + } + + async fn receiver_consent(&self, authority: ReceivingAuthority, watches: Vec) -> Result { + invoke_bool(&self.consent, vec![ + Uint8Array::from(authority.encode().as_slice()).into(), + Uint8Array::from(watches.encode().as_slice()).into(), + ]).await.map_err(generic) + } + + async fn receiver_changed(&self) -> Result<(), latest::GenericError> { + invoke_unit(&self.changed, Vec::new()).await.map_err(generic) + } + + async fn load(&self) -> Result>, latest::GenericError> { + invoke_optional_bytes_return(&self.load, Vec::new(), "readReceivingState must return bytes or undefined") + .await.map_err(generic) + } + + async fn save(&self, bytes: Vec) -> Result<(), latest::GenericError> { + invoke_unit(&self.save, vec![Uint8Array::from(bytes.as_slice()).into()]).await.map_err(generic) + } +} + +fn decode_exact(bytes: &[u8]) -> Result { + let mut input = bytes; + let value = T::decode(&mut input).map_err(|_| "invalid receiving SCALE payload".to_string())?; + if !input.is_empty() { + return Err("trailing receiving SCALE payload bytes".into()); + } + Ok(value) +} + +/// Standalone receiver with one durable writer and no wallet or product execution. +/// The host must serialize ownership across service-worker replacement and bind +/// command product IDs to trusted execution sessions, never message-body claims. +#[wasm_bindgen] +pub struct WasmNotificationReceiver { + service: Arc, +} + +#[wasm_bindgen] +impl WasmNotificationReceiver { + /// Construct from raw receiverAuthority/receiverConsent/receiverChanged, + /// readReceivingState and writeReceivingState callbacks. Persistence callbacks + /// are required; absent authority advertises unsupported, never enrollment. + #[wasm_bindgen(constructor)] + pub fn new(callbacks: JsValue) -> Result { + let backend = JsReceivingBackend { + authority: SendWrapper::new(get_optional_function(&callbacks, "receiverAuthority")? + .unwrap_or_else(super::absent_optional_callback)), + consent: SendWrapper::new(get_optional_function(&callbacks, "receiverConsent")? + .unwrap_or_else(|| super::missing_callback("receiverConsent"))), + changed: SendWrapper::new(get_optional_function(&callbacks, "receiverChanged")? + .unwrap_or_else(|| super::missing_callback("receiverChanged"))), + load: SendWrapper::new(get_function(&callbacks, "readReceivingState")?), + save: SendWrapper::new(get_function(&callbacks, "writeReceivingState")?), + }; + let spawner: crate::subscription::Spawner = Arc::new(|future| wasm_bindgen_futures::spawn_local(future)); + Ok(Self { service: Arc::new(ReceivingService::from_backend(Arc::new(backend), spawner)) }) + } + + /// Execute actions 2..7 under the immutable authority captured by the trusted + /// execution channel. Authority is SCALE ReceivingAuthority, never page input. + /// Request has no version tag; response is SCALE + /// `Result`. Action 3 requires + /// the forwarding runtime's ordinary Notifications permission authorization. + #[wasm_bindgen(js_name = commandForExecution)] + pub async fn command_for_execution(&self, authority: Vec, action: u8, payload: Vec) -> Vec { + let result = self.command_inner(&authority, action, &payload).await; + match result { + Ok(encoded) => encoded, + Err(error) => Result::<(), Error>::Err(error).encode(), + } + } + + /// All local registrations, including synchronized ones, as SCALE Vec. + #[wasm_bindgen(js_name = receivingPending)] + pub async fn receiving_pending(&self) -> Result, JsValue> { + self.service.pending().await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Acknowledge only the revision actually synchronized by transport. + #[wasm_bindgen(js_name = receivingSynchronized)] + pub async fn receiving_synchronized(&self, product_id: String, revision: u64) -> Result { + self.service.synchronized(&product_id, revision).await.map_err(receiving_error_to_js) + } + + /// Authenticate observed source metadata and carrier; returns SCALE `Vec`. + #[wasm_bindgen(js_name = receivingIngest)] + pub async fn receiving_ingest(&self, product_id: String, revision: u64, watch_id: String, + actual_genesis: String, actual_channel: String, actual_topics: Vec, frame: Vec, + ) -> Result, JsValue> { + self.service.ingest(&product_id, revision, watch_id, actual_genesis, actual_channel, actual_topics, frame) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Authenticate a SCALE statement including source metadata; returns SCALE `Vec`. + #[wasm_bindgen(js_name = receivingIngestStatement)] + pub async fn receiving_ingest_statement(&self, product_id: String, revision: u64, watch_id: String, + actual_genesis: String, statement: Vec, + ) -> Result, JsValue> { + self.service.ingest_statement(&product_id, revision, watch_id, actual_genesis, statement) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Revalidate and reserve display after foreground grace; returns SCALE `Option`. + #[wasm_bindgen(js_name = receivingPrepareDisplay)] + pub async fn receiving_prepare_display(&self, product_id: String, revision: u64, event_id: String) -> Result, JsValue> { + self.service.prepare_display(&product_id, revision, event_id).await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Confirm actual visible display, not ingestion or transport acknowledgement. + #[wasm_bindgen(js_name = receivingConfirmDisplay)] + pub async fn receiving_confirm_display(&self, product_id: String, revision: u64, event_id: String) -> Result<(), JsValue> { + self.service.confirm_display(&product_id, revision, event_id).await.map_err(receiving_error_to_js) + } + + /// Clear a reservation only after explicit display failure, not an unknown outcome. + #[wasm_bindgen(js_name = receivingCancelDisplay)] + pub async fn receiving_cancel_display(&self, product_id: String, revision: u64, event_id: String) -> Result<(), JsValue> { + self.service.cancel_display(&product_id, revision, event_id).await.map_err(receiving_error_to_js) + } + + /// Read-only click validation before loading the verified product; sequence is zero. + #[wasm_bindgen(js_name = receivingValidateActivation)] + pub async fn receiving_validate_activation(&self, product_id: String, revision: u64, event_id: String) -> Result, JsValue> { + self.service.validate_activation(&product_id, revision, event_id).await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Queue durable activation only after the matching product is ready. + #[wasm_bindgen(js_name = receivingActivate)] + pub async fn receiving_activate(&self, product_id: String, revision: u64, event_id: String) -> Result, JsValue> { + self.service.activate(&product_id, revision, event_id).await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Revoke locally before logout or destructive identity erasure. + #[wasm_bindgen(js_name = receivingRevoke)] + pub async fn receiving_revoke(&self, product_id: String) -> Result<(), JsValue> { + self.service.revoke(&product_id).await.map_err(receiving_error_to_js) + } + + /// Queue synchronization after the host durably changes the selected transport. + #[wasm_bindgen(js_name = receivingMarkTransportChanged)] + pub async fn receiving_mark_transport_changed(&self, product_id: String) -> Result<(), JsValue> { + self.service.mark_transport_changed(&product_id).await.map_err(receiving_error_to_js) + } +} + +impl WasmNotificationReceiver { + async fn command_inner(&self, authority: &[u8], action: u8, payload: &[u8]) -> Result, Error> { + if payload.len() > 2 * 1024 * 1024 || authority.len() > 4096 { + return Err(Error::Capacity); + } + fn request(payload: &[u8]) -> Result { + decode_exact(payload).map_err(|reason| Error::InvalidRequest { reason }) + } + let execution = self.service.for_execution(request::(authority)?); + match action { + 2 => { + request::<()>(payload)?; + Ok(execution.status().await.encode()) + } + 3 => { + let value: latest::HostNotificationReplaceReceiverRequest = request(payload)?; + Ok(execution.replace(value.expected_revision, value.watches).await.encode()) + } + 4 => { + let value: latest::HostNotificationDisableReceiverRequest = request(payload)?; + Ok(execution.disable(value.expected_revision).await.encode()) + } + 5 => { + let value: latest::HostNotificationRecordReceiptRequest = request(payload)?; + Ok(execution.receipt(value.revision, value.watch_id, value.event_id, value.kind).await.encode()) + } + 6 => { + let value: latest::HostNotificationReceiverEventsRequest = request(payload)?; + Ok(execution.events(value.after_sequence).await.encode()) + } + 7 => { + let value: latest::HostNotificationAcknowledgeReceiverEventRequest = request(payload)?; + Ok(execution.acknowledge(value.sequence).await.encode()) + } + _ => Err(Error::InvalidRequest { reason: "unknown receiving action".into() }), + } + } +} From 682d9c00263f36dd69130b34e02a3b9279b2f6d5 Mon Sep 17 00:00:00 2001 From: w Date: Sun, 4 Oct 2026 08:40:39 -0400 Subject: [PATCH 17/36] fix(receiving): fence closed products on account replacement Apply canonical receiving follow-up e7cd98ca271c4a83c80fe69425dfd20334b56d2b onto the isolated PVM receiving branch. Retain previously aligned NotificationReceiving storage slot 20 and all pre-existing PVM/Chat/Seity/JamPeerTransport state. Source-only application; generated artifacts and verification remain parent-owned. --- CHANGELOG.md | 1 + js/packages/truapi-host/README.md | 13 ++++- .../src/browser-receiving-worker.ts | 51 +++++++++++++++++++ .../truapi-host/src/browser-receiving.ts | 7 +++ 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36a5693d3e..f89127b971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/). - Host-owned background notification receiving on Notifications actions 2 through 7, with durable consent, revision-fenced watches and receipts, authenticated delivery and activation, and resident native/WASM transport hooks. Receipt results distinguish confirmed OS display from an outstanding display claim; explicit display failures release the claim. Hosts without an adapter explicitly report unsupported. - Generic Ed25519 notification assertions in bounded standard Gordian envelopes, with opaque byte-leaf digest witnesses and exact chain/channel plus authenticated topic filtering, exposed through `@parity/truapi/notification-envelope`. - A wallet-free, single-writer browser receiving runtime and immutable execution-scope dispatch. Receiving consent lasts only through its approved watch expiry (at most 30 days); transport leases may renew within that bound without product UI. Native/provider adapters and real-device qualification remain host responsibilities; resident hooks do not imply delivery after a desktop host fully quits. +- Browser host account selection fences all persisted product authorities, including closed products. Explicit logout durably revokes every authority before identity removal; ordinary client closure retains consent. Receiving storage uses reserved key 20 without colliding with the PolkaVM runtime's existing keys. - Foreground receipts distinguish confirmed OS display from an unresolved durable display reservation. Hosts confirm successful presentation or cancel a known failure; an unknown outcome after restart remains pending until event expiry and must not trigger a competing OS alert. Provider-rendered APNs alerts remain advisory until authenticated local processing. ### Fixed diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index bc7a7c570f..92129002bc 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -168,6 +168,13 @@ only after the exact verified product is ready in the correct unlocked account and environment. It must never silently switch accounts or navigate `event.route` as a URL. That route is an opaque product token. +Call `setActiveAccount(account, environment, genesis)` only from the host's current, +authenticated account selection, before updating or binding product authority. +Fence asynchronous login callbacks and stale tabs before this call. Replacing +that scope durably revokes mismatched authorities, including products no longer +open. Passing `undefined` pauses authority without discarding consent; do not use +it for ordinary page/product closure, suspension, or a transient network outage. + Read `getAuthority(productId)` to recover the durable generation. It returns the canonical authority plus `revoked`; reuse a live matching scope's generation across reloads, and increment it for account/artifact replacement or explicit @@ -177,8 +184,10 @@ scope, then `bindExecution` with an immutable snapshot. Forward page-core the callback product ID against the trusted execution closure. The worker calls canonical `commandForExecution`, including its post-consent scope recheck. Call execution `ready()` once the verified product is ready and `close()` on -suspension. Suspension/lock retains enrollment; logout must await local `revoke` -before forgetting identity. `revoke` never waits for relay deletion. +suspension. Suspension/lock retains enrollment. Explicit logout/account removal +must await local `revokeAll()` before forgetting identity; it covers closed +products and retains durable remote-deletion intent. Use scoped `revoke(productId)` +for artifact replacement or removing one product. Neither waits for relay deletion. Call `enableWebPush(vapidPublicKey)` directly from a user gesture. Obtain the trusted relay's VAPID key from `GET /v2/config`, not a product-provided URL. diff --git a/js/packages/truapi-host/src/browser-receiving-worker.ts b/js/packages/truapi-host/src/browser-receiving-worker.ts index acbd0947e1..eddc9a40bc 100644 --- a/js/packages/truapi-host/src/browser-receiving-worker.ts +++ b/js/packages/truapi-host/src/browser-receiving-worker.ts @@ -28,6 +28,7 @@ export interface BrowserReceivingWorkerOptions { } interface AuthorityEntry { authority: ReceivingAuthority; revoked: boolean } +interface AccountScope { account: string; environment: string; genesis: string } interface Execution { authority: ReceivingAuthority; clientId: string; ready: boolean } interface Activation { authority: ReceivingAuthority; revision: bigint; eventId: string; expiresAt: bigint } interface Wake { v: 2; deviceId: string; routeToken: string; messageId: string; revision: number } @@ -39,6 +40,10 @@ function sameScope(left: ReceivingAuthority, right: ReceivingAuthority): boolean left.genesis === right.genesis && left.generation === right.generation; } +function sameAccount(scope: AccountScope, authority: ReceivingAuthority): boolean { + return scope.account === authority.account && scope.environment === authority.environment && scope.genesis === authority.genesis; +} + class ReceiverDatabase { private readonly opened: Promise; constructor(name: string) { @@ -132,6 +137,8 @@ export function installBrowserReceivingWorker(options: BrowserReceivingWorkerOpt async function liveAuthority(product: string): Promise { const entry = await database.get(`authority:${product}`); if (!entry || entry.revoked) return undefined; + const account = await database.get("activeAccount"); + if (!account || !sameAccount(account, entry.authority)) return undefined; const destination = await database.get("destination"); return { ...entry.authority, osPermission: Notification.permission === "granted", transportReady: Boolean(destination?.endpoint) }; @@ -337,8 +344,52 @@ export function installBrowserReceivingWorker(options: BrowserReceivingWorkerOpt const entry = await database.get(`authority:${String(value)}`); return entry ? { ...entry.authority, revoked: entry.revoked } : undefined; } + case "activeAccount": { + if (!value || (value.account !== undefined && (typeof value.account !== "string" || !/^[0-9a-f]{64}$/.test(value.account))) || + typeof value.environment !== "string" || value.environment.length < 1 || value.environment.length > 128 || + typeof value.genesis !== "string" || !/^[0-9a-f]{64}$/.test(value.genesis)) throw new Error("invalid receiving account scope"); + const account: AccountScope | undefined = value.account === undefined ? undefined + : { account: value.account, environment: value.environment, genesis: value.genesis }; + // Commit this fence without waiting behind an outstanding consent prompt. + await database.put("activeAccount", account); + for (const [id, execution] of executions) { + if (!account || !sameAccount(account, execution.authority)) executions.delete(id); + } + if (account) { + for (const [key, entry] of await database.entries("authority:")) { + if (entry.revoked || sameAccount(account, entry.authority)) continue; + await database.update(key, current => current && !sameAccount(account, current.authority) + ? { ...current, revoked: true } : current); + await withCore(async receiver => { + const current = await database.get(key); + if (!current?.revoked) return; + await receiver.receivingRevoke(entry.authority.productId); + await revokeEnrollments(entry.authority.productId); + }); + } + } + return; + } + case "revokeAll": { + // Durable host-global fence comes first; absent products are included. + await database.put("activeAccount", undefined); + executions.clear(); + const entries = await database.entries("authority:"); + for (const [key] of entries) { + await database.update(key, current => current ? { ...current, revoked: true } : current); + } + await withCore(async receiver => { + for (const [, entry] of entries) { + await receiver.receivingRevoke(entry.authority.productId); + await revokeEnrollments(entry.authority.productId); + } + }); + return; + } case "authority": { const authority = ReceivingAuthority.dec(ReceivingAuthority.enc(value)); + const account = await database.get("activeAccount"); + if (!account || !sameAccount(account, authority)) throw new Error("receiving account is not active"); let replaced = false; // This transaction deliberately does not wait behind a consent prompt. Core rechecks it after consent. await database.update(`authority:${authority.productId}`, previous => { diff --git a/js/packages/truapi-host/src/browser-receiving.ts b/js/packages/truapi-host/src/browser-receiving.ts index 0ee1b5e9b0..c9fc33d8c4 100644 --- a/js/packages/truapi-host/src/browser-receiving.ts +++ b/js/packages/truapi-host/src/browser-receiving.ts @@ -22,9 +22,14 @@ export interface BrowserReceivingAuthorityState extends ReceivingAuthority { export interface BrowserReceivingClient { getAuthority(productId: string): Promise; updateAuthority(authority: ReceivingAuthority): Promise; + /** Update only from the current trusted host selection, never a product claim. + * Undefined pauses; closing a product/page must not call this method. */ + setActiveAccount(account: string | undefined, environment: string, genesis: string): Promise; bindExecution(authority: ReceivingAuthority): Promise; enableWebPush(vapidPublicKey: string): Promise; revoke(productId: string): Promise; + /** Explicit host logout/erase, including products with no open execution. */ + revokeAll(): Promise; refresh(): Promise; close(): void; } @@ -70,6 +75,7 @@ export function createBrowserReceivingClient(options: BrowserReceivingClientOpti return { getAuthority: (productId: string) => request("getAuthority", productId), updateAuthority: (authority: ReceivingAuthority) => request("authority", structuredClone(authority)), + setActiveAccount: (account, environment, genesis) => request("activeAccount", { account, environment, genesis }), async bindExecution(authority: ReceivingAuthority): Promise { const snapshot = structuredClone(authority); let id = await request("bind", snapshot); @@ -121,6 +127,7 @@ export function createBrowserReceivingClient(options: BrowserReceivingClientOpti finally { await request("refresh"); } }, revoke: (productId: string) => request("revoke", productId), + revokeAll: () => request("revokeAll"), refresh: () => request("refresh"), close() { for (const id of executions) void request("unbind", { id }).catch(() => {}); From 8b039c4396eb9fa605021c254e079847cde44759 Mon Sep 17 00:00:00 2001 From: w Date: Sun, 4 Oct 2026 08:45:43 -0400 Subject: [PATCH 18/36] chore(receiving): regenerate the PVM client contract --- rust/crates/truapi-client/src/generated.rs | 184 ++++++++++++++++++++- 1 file changed, 183 insertions(+), 1 deletion(-) diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index a1e8cb9bae..69054ea418 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "4dda7fbab9d6f435"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "7cf5d7f894407ead"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; @@ -1600,6 +1600,170 @@ impl RequestMethod for NotificationsCancelPushNotification { const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; } +/// `notifications_receiver_status` method marker. +pub struct NotificationsReceiverStatus; +impl NotificationsReceiverStatus { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Notifications", + method: "receiver_status", + wire_name: "notifications_receiver_status", + request_type: "truapi::versioned::notifications::HostNotificationReceiverStatusRequest", + response_type: "truapi::versioned::notifications::HostNotificationReceiverStatusResponse", + error_type: Some("truapi::versioned::notifications::HostNotificationReceivingError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 8, + method_id: 2, + }), + }; +} +impl RequestMethod for NotificationsReceiverStatus { + type Request = truapi::versioned::notifications::HostNotificationReceiverStatusRequest; + type Response = truapi::versioned::notifications::HostNotificationReceiverStatusResponse; + type Error = truapi::versioned::notifications::HostNotificationReceivingError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `notifications_replace_receiver` method marker. +pub struct NotificationsReplaceReceiver; +impl NotificationsReplaceReceiver { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Notifications", + method: "replace_receiver", + wire_name: "notifications_replace_receiver", + request_type: "truapi::versioned::notifications::HostNotificationReplaceReceiverRequest", + response_type: "truapi::versioned::notifications::HostNotificationReplaceReceiverResponse", + error_type: Some("truapi::versioned::notifications::HostNotificationReceivingError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 8, + method_id: 3, + }), + }; +} +impl RequestMethod for NotificationsReplaceReceiver { + type Request = truapi::versioned::notifications::HostNotificationReplaceReceiverRequest; + type Response = truapi::versioned::notifications::HostNotificationReplaceReceiverResponse; + type Error = truapi::versioned::notifications::HostNotificationReceivingError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `notifications_disable_receiver` method marker. +pub struct NotificationsDisableReceiver; +impl NotificationsDisableReceiver { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Notifications", + method: "disable_receiver", + wire_name: "notifications_disable_receiver", + request_type: "truapi::versioned::notifications::HostNotificationDisableReceiverRequest", + response_type: "truapi::versioned::notifications::HostNotificationDisableReceiverResponse", + error_type: Some("truapi::versioned::notifications::HostNotificationReceivingError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 8, + method_id: 4, + }), + }; +} +impl RequestMethod for NotificationsDisableReceiver { + type Request = truapi::versioned::notifications::HostNotificationDisableReceiverRequest; + type Response = truapi::versioned::notifications::HostNotificationDisableReceiverResponse; + type Error = truapi::versioned::notifications::HostNotificationReceivingError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `notifications_record_receipt` method marker. +pub struct NotificationsRecordReceipt; +impl NotificationsRecordReceipt { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Notifications", + method: "record_receipt", + wire_name: "notifications_record_receipt", + request_type: "truapi::versioned::notifications::HostNotificationRecordReceiptRequest", + response_type: "truapi::versioned::notifications::HostNotificationRecordReceiptResponse", + error_type: Some("truapi::versioned::notifications::HostNotificationReceivingError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 8, + method_id: 5, + }), + }; +} +impl RequestMethod for NotificationsRecordReceipt { + type Request = truapi::versioned::notifications::HostNotificationRecordReceiptRequest; + type Response = truapi::versioned::notifications::HostNotificationRecordReceiptResponse; + type Error = truapi::versioned::notifications::HostNotificationReceivingError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `notifications_receiver_events` method marker. +pub struct NotificationsReceiverEvents; +impl NotificationsReceiverEvents { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Notifications", + method: "receiver_events", + wire_name: "notifications_receiver_events", + request_type: "truapi::versioned::notifications::HostNotificationReceiverEventsRequest", + response_type: "truapi::versioned::notifications::HostNotificationReceiverEventsResponse", + error_type: Some("truapi::versioned::notifications::HostNotificationReceivingError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 8, + method_id: 6, + }), + }; +} +impl RequestMethod for NotificationsReceiverEvents { + type Request = truapi::versioned::notifications::HostNotificationReceiverEventsRequest; + type Response = truapi::versioned::notifications::HostNotificationReceiverEventsResponse; + type Error = truapi::versioned::notifications::HostNotificationReceivingError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `notifications_acknowledge_receiver_event` method marker. +pub struct NotificationsAcknowledgeReceiverEvent; +impl NotificationsAcknowledgeReceiverEvent { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Notifications", + method: "acknowledge_receiver_event", + wire_name: "notifications_acknowledge_receiver_event", + request_type: "truapi::versioned::notifications::HostNotificationAcknowledgeReceiverEventRequest", + response_type: "truapi::versioned::notifications::HostNotificationAcknowledgeReceiverEventResponse", + error_type: Some("truapi::versioned::notifications::HostNotificationReceivingError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 8, + method_id: 7, + }), + }; +} +impl RequestMethod for NotificationsAcknowledgeReceiverEvent { + type Request = + truapi::versioned::notifications::HostNotificationAcknowledgeReceiverEventRequest; + type Response = + truapi::versioned::notifications::HostNotificationAcknowledgeReceiverEventResponse; + type Error = truapi::versioned::notifications::HostNotificationReceivingError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + /// `payment_balance_subscribe` method marker. pub struct PaymentBalanceSubscribe; impl PaymentBalanceSubscribe { @@ -2798,6 +2962,12 @@ pub const APP_METHODS: &[MethodDescriptor] = &[ LocaleLocalizeTimestamps::DESCRIPTOR, NotificationsSendPushNotification::DESCRIPTOR, NotificationsCancelPushNotification::DESCRIPTOR, + NotificationsReceiverStatus::DESCRIPTOR, + NotificationsReplaceReceiver::DESCRIPTOR, + NotificationsDisableReceiver::DESCRIPTOR, + NotificationsRecordReceipt::DESCRIPTOR, + NotificationsReceiverEvents::DESCRIPTOR, + NotificationsAcknowledgeReceiverEvent::DESCRIPTOR, PaymentBalanceSubscribe::DESCRIPTOR, PaymentRequest::DESCRIPTOR, PaymentStatusSubscribe::DESCRIPTOR, @@ -2892,6 +3062,12 @@ pub const WIDGET_METHODS: &[MethodDescriptor] = &[ LocaleLocalizeTimestamps::DESCRIPTOR, NotificationsSendPushNotification::DESCRIPTOR, NotificationsCancelPushNotification::DESCRIPTOR, + NotificationsReceiverStatus::DESCRIPTOR, + NotificationsReplaceReceiver::DESCRIPTOR, + NotificationsDisableReceiver::DESCRIPTOR, + NotificationsRecordReceipt::DESCRIPTOR, + NotificationsReceiverEvents::DESCRIPTOR, + NotificationsAcknowledgeReceiverEvent::DESCRIPTOR, PaymentBalanceSubscribe::DESCRIPTOR, PaymentRequest::DESCRIPTOR, PaymentStatusSubscribe::DESCRIPTOR, @@ -2991,6 +3167,12 @@ pub const WORKER_METHODS: &[MethodDescriptor] = &[ LocaleLocalizeTimestamps::DESCRIPTOR, NotificationsSendPushNotification::DESCRIPTOR, NotificationsCancelPushNotification::DESCRIPTOR, + NotificationsReceiverStatus::DESCRIPTOR, + NotificationsReplaceReceiver::DESCRIPTOR, + NotificationsDisableReceiver::DESCRIPTOR, + NotificationsRecordReceipt::DESCRIPTOR, + NotificationsReceiverEvents::DESCRIPTOR, + NotificationsAcknowledgeReceiverEvent::DESCRIPTOR, PaymentBalanceSubscribe::DESCRIPTOR, PaymentRequest::DESCRIPTOR, PaymentStatusSubscribe::DESCRIPTOR, From df91f8f5aaedab0c89b3ff6b7c4a34cc6ce990d2 Mon Sep 17 00:00:00 2001 From: w Date: Sun, 4 Oct 2026 08:57:42 -0400 Subject: [PATCH 19/36] fix(host): handle an unavailable HOP callback --- js/packages/truapi-host/src/web/create-worker-host-runtime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts index e3e74098e3..4b17530c74 100644 --- a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts +++ b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts @@ -858,7 +858,7 @@ async function handleChainConnectStart( }; try { const conn = await (msg.kind === "hopConnectStart" - ? state.rawCallbacks.hopConnect( + ? state.rawCallbacks.hopConnect?.( msg.genesisHash, msg.endpoint, onResponse, From 3c3c620e1e7cca19f179b51d6443a6d993900ae9 Mon Sep 17 00:00:00 2001 From: w Date: Sun, 4 Oct 2026 10:01:51 -0400 Subject: [PATCH 20/36] fix(receiving): align generated storage and native callback contracts --- CHANGELOG.md | 2 ++ .../Sources/TrUAPIHost/TrUAPIHost.swift | 4 ++-- .../src/host-callbacks-adapter.test.ts | 6 +++++- .../truapi-codegen/src/rust/wasm_bridge.rs | 2 ++ .../truapi-codegen/src/ts/host_callbacks.rs | 21 ++++++++++++------- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f89127b971..9afe864103 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,8 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/). - Message catch-up receipts preserve an already queued notification activation until the product explicitly acknowledges its sequence. - Generated WASM bridges preserve owned `String` parameters instead of emitting unsized `str` arguments. +- Generated host tagged-union codecs preserve explicit SCALE discriminants, including receiving storage slot 20 when slots 13–19 are absent from the generic runtime. +- Swift's unsupported receiving callbacks use the generated native rejection case. - persist typed Statement Store allowance approvals and denials per product and account selector for implicit, idempotent provisioning; explicit requests for diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index e62677de57..22ca70c951 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -373,10 +373,10 @@ public extension HostBridge { func cancelNotification(id: UInt32) throws {} func receiverAuthority(productId: String) async throws -> ReceivingAuthority? { nil } func receiverConsent(authority: ReceivingAuthority, watches: [ReceivingWatch]) async throws -> Bool { - throw HostRejection.rejected(reason: "background receiving unsupported") + throw HostRejection.Rejected(reason: "background receiving unsupported") } func receiverChanged() async throws { - throw HostRejection.rejected(reason: "background receiving unsupported") + throw HostRejection.Rejected(reason: "background receiving unsupported") } func receiverCommand(productId: String, action: UInt8, payload: Data) async throws -> Data? { nil } func authStateChanged(state: AuthState) {} diff --git a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts index e26904e044..960e001899 100644 --- a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts +++ b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts @@ -40,7 +40,6 @@ import { UserConfirmationReview, } from "./generated/host-callbacks.js"; import { makeHostCallbacks, settle } from "./test-support.js"; -import { Vector } from "@parity/truapi/scale"; import { createNotificationReceiverCallbacks } from "./runtime.js"; // The generated `createWasmRawCallbacks` adapter speaks the symmetric SCALE @@ -50,6 +49,11 @@ import { createNotificationReceiverCallbacks } from "./runtime.js"; const GENESIS = `0x${"11".repeat(32)}` as `0x${string}`; +it("keeps receiving state at its native SCALE slot rather than another host capability's slot", () => { + expect(CoreStorageKey.enc({ tag: "NotificationReceiving" })).toEqual(new Uint8Array([20])); + expect(CoreStorageKey.dec(new Uint8Array([20])).tag).toBe("NotificationReceiving"); +}); + it("preserves one-use permission decisions across the WASM callback", async () => { const review = { tag: "IdentityDisclosure" as const, diff --git a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs index 8c7e02f157..48d09a6fab 100644 --- a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -881,8 +881,10 @@ mod tests { use crate::platform::{PlatformParam, PlatformReturn}; fn context() -> BridgeCtx<'static> { + static API_TYPE_PATHS: BTreeMap = BTreeMap::new(); BridgeCtx { api_types: BTreeMap::new(), + api_type_paths: &API_TYPE_PATHS, codec_types: BTreeSet::new(), local_types: ["ReceivingAuthority"].into_iter().collect(), local_codec_types: ["ReceivingAuthority"].into_iter().collect(), diff --git a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs index a3915c417a..072e3c91ba 100644 --- a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -1458,18 +1458,25 @@ fn local_codec_expr_for_type(type_def: &TypeDef) -> Result { .join(", ") )); } + let indexed = variants.iter().enumerate().any(|(position, variant)| { + variant.codec_index.is_some_and(|index| index as usize != position) + }); let entries = variants .iter() - .map(|variant| { - Ok(format!( - "{}: {}", - variant.name, - local_variant_codec_expr(&variant.fields)? - )) + .enumerate() + .map(|(position, variant)| { + let codec = local_variant_codec_expr(&variant.fields)?; + if indexed { + let index = variant.codec_index.map_or(position, |index| index as usize); + Ok(format!("{}: [{index}, {codec}] as const", variant.name)) + } else { + Ok(format!("{}: {codec}", variant.name)) + } }) .collect::>>()? .join(", "); - Ok(format!("S.TaggedUnion({{{entries}}})")) + let constructor = if indexed { "indexedTaggedUnion" } else { "TaggedUnion" }; + Ok(format!("S.{constructor}({{{entries}}})")) } } } From 367d76a535c4f507540879cc54ea19230451f76c Mon Sep 17 00:00:00 2001 From: w Date: Sun, 4 Oct 2026 20:41:55 -0400 Subject: [PATCH 21/36] fix: scope browser receiving synchronization to each authority --- CHANGELOG.md | 1 + README.md | 3 +++ js/packages/truapi-host/README.md | 5 +++++ .../truapi-host/src/browser-receiving-worker.ts | 11 ++++++----- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9afe864103..dd20649641 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/). ### Fixed +- Browser receiving synchronizes and revokes each authority scope independently, so account or verified-artifact replacement cannot leave the current enrollment in a revoke/register loop. - Message catch-up receipts preserve an already queued notification activation until the product explicitly acknowledges its sequence. - Generated WASM bridges preserve owned `String` parameters instead of emitting unsized `str` arguments. - Generated host tagged-union codecs preserve explicit SCALE discriminants, including receiving storage slot 20 when slots 13–19 are absent from the generic runtime. diff --git a/README.md b/README.md index e50a501fc5..7ab65a0a50 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,9 @@ separate consent; unsupported hosts report that explicitly. Logout revokes local without waiting for a relay. See the [host receiving contract](js/packages/truapi-host/README.md) and [product notification helpers](js/packages/truapi/README.md). These hooks do not establish OS/provider delivery guarantees or replace the separate PolkaVM runtime. +Relay revocation and synchronization acknowledgements use the full receiving +authority, so retained records from a previous account or verified artifact cannot +revoke the current enrollment. The shared Rust core asks blessed products (`peopl`, `dim2` and `stash`, on every supported network) only for device permissions and legacy-account signing. diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 92129002bc..e01bcb81ce 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -132,6 +132,11 @@ core access with Web Locks across worker replacement, and uses the generic relay v2 WebPush endpoints. Products never receive its authority registry, transport credentials, or raw host hooks. +Transport synchronization matches the complete product, account, environment, +artifact, genesis and generation scope. Disabled records from an older scope do +not revoke or block acknowledgement of a current enrollment. Explicit product +revocation and host logout still revoke every applicable enrollment. + ```ts import init, { WasmNotificationReceiver } from "@parity/truapi-host/wasm/web"; import { installBrowserReceivingWorker } from "@parity/truapi-host/browser-receiving-worker"; diff --git a/js/packages/truapi-host/src/browser-receiving-worker.ts b/js/packages/truapi-host/src/browser-receiving-worker.ts index eddc9a40bc..fb5341259f 100644 --- a/js/packages/truapi-host/src/browser-receiving-worker.ts +++ b/js/packages/truapi-host/src/browser-receiving-worker.ts @@ -176,9 +176,10 @@ export function installBrowserReceivingWorker(options: BrowserReceivingWorkerOpt await periodic.periodicSync?.register("truapi:receiving", { minInterval: 12 * 60 * 60 * 1000 }).catch(() => {}); } - async function revokeEnrollments(product: string): Promise { + async function revokeEnrollments(product: string, authority?: ReceivingAuthority): Promise { for (const [key, enrollment] of await database.entries("enrollment:")) { if (enrollment.authority.productId !== product || enrollment.revoked) continue; + if (authority && !sameScope(enrollment.authority, authority)) continue; await database.put(key, { ...enrollment, revoked: true, acknowledged: false, relayRevision: enrollment.relayRevision + 1 }); } } @@ -212,8 +213,8 @@ export function installBrowserReceivingWorker(options: BrowserReceivingWorkerOpt for (const registration of registrations) { const product = registration.authority.productId; if (!registration.enabled) { - await revokeEnrollments(product); - const existing = (await database.entries("enrollment:")).some(([, item]) => item.authority.productId === product); + await revokeEnrollments(product, registration.authority); + const existing = (await database.entries("enrollment:")).some(([, item]) => sameScope(item.authority, registration.authority)); if (!existing && registration.syncPending) await receiver.receivingSynchronized(product, registration.revision); continue; } @@ -266,9 +267,9 @@ export function installBrowserReceivingWorker(options: BrowserReceivingWorkerOpt if (!current || current.relayRevision !== enrollment.relayRevision || current.revoked !== enrollment.revoked) return; await database.put(`enrollment:${enrollment.deviceId}`, enrollment.revoked ? undefined : { ...current, acknowledged: true }); const pending = receivingRegistrationsCodec.dec(await receiver.receivingPending()) - .find(value => value.authority.productId === enrollment.authority.productId); + .find(value => sameScope(value.authority, enrollment.authority)); const deletionPending = (await database.entries("enrollment:")) - .some(([, value]) => value.authority.productId === enrollment.authority.productId && value.revoked && !value.acknowledged); + .some(([, value]) => sameScope(value.authority, enrollment.authority) && value.revoked && !value.acknowledged); if (pending && pending.enabled === !enrollment.revoked && (enrollment.revoked ? !deletionPending : pending.revision === enrollment.coreRevision && sameScope(pending.authority, enrollment.authority))) { await receiver.receivingSynchronized(pending.authority.productId, pending.revision); From 2c87cf1a5a3200aa948477bf8ce36ef5e5252a40 Mon Sep 17 00:00:00 2001 From: w Date: Mon, 5 Oct 2026 14:28:27 -0400 Subject: [PATCH 22/36] fix(truapi): scope authority revocation and withdraw cancelled approvals --- .../scoped-authority-approval-lifecycle.md | 6 + README.md | 17 + .../truapi-host-cli/src/frame_server.rs | 22 +- .../crates/truapi-host-cli/src/terminal_ui.rs | 194 +++++++- rust/crates/truapi/src/runtime.rs | 47 +- rust/crates/truapi/src/runtime/authority.rs | 6 + .../src/runtime/capabilities/resources.rs | 33 +- .../src/runtime/capabilities/signing.rs | 76 ++- .../src/runtime/native_chat/background.rs | 7 +- .../truapi/src/runtime/profile/avatars.rs | 2 +- .../crates/truapi/src/runtime/signing_host.rs | 454 ++++++++++++++---- .../src/runtime/signing_host/sso_responder.rs | 36 +- .../src/runtime/signing_host/sso_service.rs | 8 +- .../signing_host/tests/allowance_keys.rs | 178 ++++++- rust/crates/truapi/src/runtime/tests.rs | 153 ++++++ .../truapi/src/runtime/tests/signing.rs | 56 +++ rust/crates/truapi/src/test_support.rs | 5 + 17 files changed, 1134 insertions(+), 166 deletions(-) create mode 100644 .changeset/scoped-authority-approval-lifecycle.md diff --git a/.changeset/scoped-authority-approval-lifecycle.md b/.changeset/scoped-authority-approval-lifecycle.md new file mode 100644 index 0000000000..0aec3df951 --- /dev/null +++ b/.changeset/scoped-authority-approval-lifecycle.md @@ -0,0 +1,6 @@ +--- +"@parity/truapi": patch +"@parity/truapi-host": patch +--- + +Product clearing revokes only that product's in-flight signing, resource allocation and contact authority, while account replacement or disconnection revokes all prior authority. Resource reviews and allowance-cache commits revalidate their product and account, and contact selection and labels retain independent directory-mutation fences. Cancelled CLI signing requests withdraw their permission and signature reviews, including on product disconnection, without authorizing late answers or losing the command draft. diff --git a/README.md b/README.md index 7ab65a0a50..de1b0c9e6b 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,12 @@ session name requires at least six lowercase ASCII letters after digits and separators are omitted. A new `/session foo` fails immediately as too short; existing saved accounts and aliases still restore normally. +CLI approval reviews belong to their waiting request. Cancelling a signing +request withdraws both its chain-permission prerequisite and its signature +review; disconnecting its product socket also removes the review. Withdrawn +queued reviews are skipped, late answers cannot authorize them, and the command +draft is restored before the next request is reviewed. + The signing host registers its built-in full and lite personhood keys when an authorized product first lists `peopl.` (for example, `peopl.paseo`). The first listing reads People-chain metadata; later listings @@ -68,6 +74,17 @@ handles discoverable; proof creation still checks permission and ring membership The `listRingVrfKeys` example checks that both built-in keys are discoverable under `peopl.paseo` on Paseo. +Local signing-host product clearing revokes only that product's grants and +in-flight authority. Unrelated products and account-scoped authority remain valid. +Reactivating, replacing or clearing the account still invalidates every prior +authority snapshot. Resource reviews and allowance-cache commits revalidate +the relevant product and account before granting or retaining authority. +Contact resolution, picking and labels use the same product-scoped authority +check. Independent directory-mutation fences still apply: product clearing +invalidates the shared contact directory, so an interrupted selection must be +retried without treating an unrelated product clear as account disconnection. +Labels invalidated while drawing are withdrawn before the call returns. + Preimage lookups that miss the core's cache read the selected network's Bulletin node through `bitswap_v1_get`. The CLI verifies the returned bytes against the requested key and keeps missing lookups subscribed until the blob arrives. diff --git a/rust/crates/truapi-host-cli/src/frame_server.rs b/rust/crates/truapi-host-cli/src/frame_server.rs index e60a2a3a3c..4e3bfdbbc5 100644 --- a/rust/crates/truapi-host-cli/src/frame_server.rs +++ b/rust/crates/truapi-host-cli/src/frame_server.rs @@ -771,6 +771,7 @@ mod tests { second_dispatch_finished: Notify, dispatch_cancelled: Arc, dispose_calls: AtomicUsize, + ui: Option, } struct DispatchCancellation(Arc); @@ -790,7 +791,12 @@ mod tests { } let _cancellation = DispatchCancellation(self.dispatch_cancelled.clone()); self.dispatch_started.notify_one(); - std::future::pending().await + if let Some(ui) = &self.ui { + ui.confirm("sign raw", "pending product payload").await; + Ok(()) + } else { + std::future::pending().await + } } fn dispose(&self) { @@ -1034,7 +1040,17 @@ mod tests { async fn disconnect_cancels_pending_dispatch_and_disposes_runtime() -> Result<()> { let listener = TcpListener::bind("127.0.0.1:0").await?; let address = listener.local_addr()?; - let runtime = Arc::new(PendingRuntime::default()); + let (mut ui, handle) = crate::terminal_ui::TerminalUi::new( + "testnet", + "localhost:3000", + "default", + Vec::new(), + "info".into(), + ); + let runtime = Arc::new(PendingRuntime { + ui: Some(handle), + ..PendingRuntime::default() + }); let server_runtime = runtime.clone(); let product = ProductSelection::new("localhost:3000".into(), ProductExecutionKind::App)?; let product_updates = product.subscribe(); @@ -1059,6 +1075,7 @@ mod tests { let (mut websocket, _) = client_async("ws://localhost/", stream).await?; websocket.send(Message::Binary(vec![0])).await?; tokio::time::timeout(Duration::from_secs(1), runtime.dispatch_started.notified()).await?; + assert!(ui.has_pending_approval()); websocket.send(Message::Binary(vec![1])).await?; tokio::time::timeout( Duration::from_secs(1), @@ -1068,6 +1085,7 @@ mod tests { drop(websocket); tokio::time::timeout(Duration::from_secs(1), server).await???; + assert!(!ui.has_pending_approval()); assert_eq!( ( runtime.dispose_calls.load(Ordering::SeqCst), diff --git a/rust/crates/truapi-host-cli/src/terminal_ui.rs b/rust/crates/truapi-host-cli/src/terminal_ui.rs index 1d7f2ecc65..5fb0be8195 100644 --- a/rust/crates/truapi-host-cli/src/terminal_ui.rs +++ b/rust/crates/truapi-host-cli/src/terminal_ui.rs @@ -358,6 +358,7 @@ enum UiEvent { kind: ApprovalKind, response: oneshot::Sender, }, + ApprovalWithdrawn, ChatFiles { detail: String, max_files: u32, @@ -606,6 +607,21 @@ pub struct UiHandle { sender: mpsc::UnboundedSender, } +struct ApprovalAnswer<'a> { + answer: oneshot::Receiver, + sender: Option<&'a mpsc::UnboundedSender>, +} + +impl Drop for ApprovalAnswer<'_> { + fn drop(&mut self) { + if let Some(sender) = self.sender { + // Close before waking the UI when the owning call is dropped. + self.answer.close(); + let _ = sender.send(UiEvent::ApprovalWithdrawn); + } + } +} + impl UiHandle { /// Add a successful human-facing outcome to the transcript. pub fn success(&self, title: impl Into, detail: Option) { @@ -675,7 +691,15 @@ impl UiHandle { { return PermissionDecision::Deny; } - answer.await.unwrap_or(PermissionDecision::Deny) + let mut answer = ApprovalAnswer { + answer, + sender: Some(&self.sender), + }; + let decision = (&mut answer.answer) + .await + .unwrap_or(PermissionDecision::Deny); + answer.sender = None; + decision } /// Collect paths only through the existing terminal event owner. Input is @@ -707,6 +731,15 @@ pub struct TerminalUi { } impl TerminalUi { + /// Process queued events and report whether a review still owns the editor. + #[cfg(test)] + pub fn has_pending_approval(&mut self) -> bool { + while let Ok(event) = self.receiver.try_recv() { + self.app.handle_event(event); + } + self.app.pending_approval.is_some() + } + /// Create a terminal transcript and its cloneable host bridge. pub fn new( network: impl Into, @@ -1628,6 +1661,7 @@ impl App { } fn handle_event(&mut self, event: UiEvent) { + self.withdraw_closed_approval(); match event { UiEvent::Log(text) => self.push(FeedItem::Log(text)), UiEvent::Notice { @@ -1655,6 +1689,9 @@ impl App { kind, response, } => { + if response.is_closed() { + return; + } if self.pending_approval.is_some() || self.pending_chat_files.is_some() { let _ = response.send(PermissionDecision::Deny); self.notice( @@ -1681,6 +1718,7 @@ impl App { saved_input, }); } + UiEvent::ApprovalWithdrawn => {} UiEvent::ChatFiles { detail, max_files, @@ -2282,6 +2320,9 @@ impl App { } fn handle_approval_key(&mut self, key: KeyEvent) { + if self.withdraw_closed_approval() { + return; + } let Some(pending) = &self.pending_approval else { return; }; @@ -2319,6 +2360,22 @@ impl App { } } + fn withdraw_closed_approval(&mut self) -> bool { + if !self + .pending_approval + .as_ref() + .is_some_and(|pending| pending.response.is_closed()) + { + return false; + } + let pending = self.pending_approval.take().expect("closed approval"); + self.entries + .retain(|entry| !matches!(entry, FeedItem::Approval { id, .. } if *id == pending.id)); + self.recalculate_retained(); + self.editor.set_text(pending.saved_input); + true + } + fn answer_approval(&mut self, approved: PermissionDecision) { let Some(pending) = self.pending_approval.take() else { return; @@ -3402,6 +3459,141 @@ mod tests { ) } + fn test_ui() -> (TerminalUi, UiHandle) { + TerminalUi::new( + "testnet", + "playground.dot", + "default", + Vec::new(), + "info".into(), + ) + } + + #[tokio::test] + async fn cancelling_a_visible_approval_restores_the_draft_without_a_decision() { + let (mut ui, handle) = test_ui(); + ui.app.editor.set_text("/script draft.ts"); + let (abort, registration) = futures::future::AbortHandle::new_pair(); + let mut call = Box::pin(futures::future::Abortable::new( + handle.decide("sign raw", "withdrawn payload", ApprovalKind::Action), + registration, + )); + assert!(futures::poll!(call.as_mut()).is_pending()); + assert!(ui.has_pending_approval()); + ui.app.editor.set_text("unfinished answer"); + + abort.abort(); + assert!(call.await.is_err()); + assert!(!ui.has_pending_approval()); + assert_eq!(ui.app.editor.text(), "/script draft.ts"); + assert!(!ui.app.transcript_text().contains("withdrawn payload")); + + let mut next = Box::pin(handle.decide("sign raw", "next payload", ApprovalKind::Action)); + assert!(futures::poll!(next.as_mut()).is_pending()); + assert!(ui.has_pending_approval()); + ui.app + .handle_approval_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + assert_eq!(next.await, PermissionDecision::AllowAlways); + assert_eq!(ui.app.editor.text(), "/script draft.ts"); + } + + #[tokio::test] + async fn cancelling_a_queued_approval_never_replaces_the_editor() { + let (mut ui, handle) = test_ui(); + ui.app.editor.set_text("/script draft.ts"); + let mut call = + Box::pin(handle.decide("sign raw", "withdrawn payload", ApprovalKind::Action)); + assert!(futures::poll!(call.as_mut()).is_pending()); + drop(call); + + assert!(!ui.has_pending_approval()); + assert_eq!(ui.app.editor.text(), "/script draft.ts"); + assert!(!ui.app.transcript_text().contains("withdrawn payload")); + } + + #[tokio::test] + async fn a_late_key_cannot_answer_a_withdrawn_approval() { + let (mut ui, handle) = test_ui(); + ui.app.editor.set_text("/script draft.ts"); + let mut call = + Box::pin(handle.decide("sign raw", "withdrawn payload", ApprovalKind::Action)); + assert!(futures::poll!(call.as_mut()).is_pending()); + assert!(ui.has_pending_approval()); + drop(call); + + // The key can win the event-loop race against the withdrawal wake. + ui.app + .handle_approval_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + assert!(ui.app.pending_approval.is_none()); + assert_eq!(ui.app.editor.text(), "/script draft.ts"); + assert!(!ui.app.transcript_text().contains("withdrawn payload")); + } + + #[tokio::test] + async fn a_queued_successor_survives_the_previous_approval_withdrawal() { + let (mut ui, handle) = test_ui(); + let mut first = Box::pin(handle.decide("first", "first payload", ApprovalKind::Action)); + assert!(futures::poll!(first.as_mut()).is_pending()); + assert!(ui.has_pending_approval()); + let mut next = Box::pin(handle.decide("next", "next payload", ApprovalKind::Action)); + assert!(futures::poll!(next.as_mut()).is_pending()); + drop(first); + + // The next request was enqueued before the old request's withdrawal. + assert!(ui.has_pending_approval()); + assert!(!ui.app.transcript_text().contains("first payload")); + assert!(ui.app.transcript_text().contains("next payload")); + assert!(futures::poll!(next.as_mut()).is_pending()); + ui.app + .handle_approval_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + assert_eq!(next.await, PermissionDecision::Deny); + } + + #[tokio::test] + async fn rejecting_an_overlap_does_not_withdraw_the_active_approval() { + let (mut ui, handle) = test_ui(); + let mut first = Box::pin(handle.decide("first", "first payload", ApprovalKind::Action)); + assert!(futures::poll!(first.as_mut()).is_pending()); + assert!(ui.has_pending_approval()); + let mut overlap = Box::pin(handle.decide("overlap", "other payload", ApprovalKind::Action)); + assert!(futures::poll!(overlap.as_mut()).is_pending()); + assert!(ui.has_pending_approval()); + assert_eq!(overlap.await, PermissionDecision::Deny); + assert!(ui.has_pending_approval()); + assert!(futures::poll!(first.as_mut()).is_pending()); + ui.app + .handle_approval_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + assert_eq!(first.await, PermissionDecision::AllowAlways); + } + + #[tokio::test] + async fn cancelling_platform_approvals_releases_the_prompt_lock_in_order() { + let (mut ui, handle) = test_ui(); + let platform = crate::platform::CliPlatform::new( + crate::network::Network::default().config(), + None, + crate::platform::ApprovalPolicy::Prompt, + Some(handle), + ); + let mut first = Box::pin(platform.decide("first", "first payload".into())); + assert!(futures::poll!(first.as_mut()).is_pending()); + assert!(ui.has_pending_approval()); + let mut withdrawn = Box::pin(platform.decide("withdrawn", "queued payload".into())); + assert!(futures::poll!(withdrawn.as_mut()).is_pending()); + let mut next = Box::pin(platform.decide("next", "next payload".into())); + assert!(futures::poll!(next.as_mut()).is_pending()); + drop(withdrawn); + drop(first); + assert!(futures::poll!(next.as_mut()).is_pending()); + assert!(ui.has_pending_approval()); + assert!(!ui.app.transcript_text().contains("first payload")); + assert!(!ui.app.transcript_text().contains("queued payload")); + assert!(ui.app.transcript_text().contains("next payload")); + ui.app + .handle_approval_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + assert!(next.await); + } + #[test] fn approval_preserves_the_decision_and_restores_command_draft() { for (kind, key, expected) in [ diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index beebf6ffac..d12aae347d 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -1482,7 +1482,10 @@ impl ProductRuntimeHost { let resolved = resolve_contact_accounts(&self.services, platform.as_ref(), &handles, requested) .await?; - if self.authority.current_session() != session { + if !session.as_ref().is_some_and(|session| { + self.authority + .session_is_current(session, Some(&self.product.product_id)) + }) { return Err(ContactResolutionError::NotConnected); } Ok(resolved) @@ -1597,7 +1600,10 @@ impl Contacts for ProductRuntimeHost { // Read before the picker opens: a removal signalled while the user is // choosing must not be undone by caching their choice. let generation = self.services.contact_handles.generation(); - if self.authority.current_session() != session { + if !session.as_ref().is_some_and(|session| { + self.authority + .session_is_current(session, Some(&self.product.product_id)) + }) { return Err(CallError::Domain(wrap( v01::HostContactsPickError::NotConnected, ))); @@ -1609,7 +1615,10 @@ impl Contacts for ProductRuntimeHost { reason: "contact picker interrupted".into(), }) })?; - if self.authority.current_session() != session { + if !session.as_ref().is_some_and(|session| { + self.authority + .session_is_current(session, Some(&self.product.product_id)) + }) { return Err(CallError::Domain(wrap( v01::HostContactsPickError::NotConnected, ))); @@ -1664,7 +1673,10 @@ impl Contacts for ProductRuntimeHost { }), })?; let generation = self.services.contact_handles.generation(); - if self.authority.current_session() != session { + if !session.as_ref().is_some_and(|session| { + self.authority + .session_is_current(session, Some(&self.product.product_id)) + }) { return Err(error(Error::NotConnected)); } let resolved = until_cancelled( @@ -1677,7 +1689,10 @@ impl Contacts for ProductRuntimeHost { reason: "contact lookup interrupted".into(), }) })?; - if self.authority.current_session() != session { + if !session.as_ref().is_some_and(|session| { + self.authority + .session_is_current(session, Some(&self.product.product_id)) + }) { return Err(error(Error::NotConnected)); } if self.services.contact_handles.generation() != generation || cx.cancel().is_cancelled() { @@ -1708,7 +1723,10 @@ impl Contacts for ProductRuntimeHost { reason: "contact picker interrupted".into(), }) })?; - if self.authority.current_session() != session { + if !session.as_ref().is_some_and(|session| { + self.authority + .session_is_current(session, Some(&self.product.product_id)) + }) { return Err(error(Error::NotConnected)); } if self.services.contact_handles.generation() != generation || cx.cancel().is_cancelled() { @@ -1783,7 +1801,10 @@ impl Contacts for ProductRuntimeHost { return Err(error(Error::NotConnected)); } let generation = self.services.contact_handles.generation(); - if self.authority.current_session() != session { + if !session.as_ref().is_some_and(|session| { + self.authority + .session_is_current(session, Some(&self.product.product_id)) + }) { return Err(error(Error::NotConnected)); } let requested: Vec<_> = request.slots.iter().map(|slot| slot.handle.bytes).collect(); @@ -1797,7 +1818,10 @@ impl Contacts for ProductRuntimeHost { reason: "contact lookup interrupted".into(), }) })?; - if self.authority.current_session() != session { + if !session.as_ref().is_some_and(|session| { + self.authority + .session_is_current(session, Some(&self.product.product_id)) + }) { return Err(error(Error::NotConnected)); } if self.services.contact_handles.generation() != generation || cx.cancel().is_cancelled() { @@ -1837,9 +1861,12 @@ impl Contacts for ProductRuntimeHost { }, ) .await; - if self.authority.current_session() != session - || cx.cancel().is_cancelled() + if !session.as_ref().is_some_and(|session| { + self.authority + .session_is_current(session, Some(&self.product.product_id)) + }) || cx.cancel().is_cancelled() || placement.is_closed() + || self.services.contact_handles.generation() != generation { let _ = platform .place_contact_labels( diff --git a/rust/crates/truapi/src/runtime/authority.rs b/rust/crates/truapi/src/runtime/authority.rs index e2d6f10134..1367de39b4 100644 --- a/rust/crates/truapi/src/runtime/authority.rs +++ b/rust/crates/truapi/src/runtime/authority.rs @@ -445,6 +445,12 @@ pub trait ProductAuthority: Send + Sync { /// Current account-authority session, if connected. fn current_session(&self) -> Option; + /// Whether a snapshot still authorizes work for this product, or for the + /// account itself when no product is supplied. + fn session_is_current(&self, session: &AuthoritySession, _product_id: Option<&str>) -> bool { + self.current_session().as_ref() == Some(session) + } + /// Shared session holder owned by this authority. /// /// Product runtimes use it for connection-status subscriptions. The diff --git a/rust/crates/truapi/src/runtime/capabilities/resources.rs b/rust/crates/truapi/src/runtime/capabilities/resources.rs index 40efb3b061..fdc49e8977 100644 --- a/rust/crates/truapi/src/runtime/capabilities/resources.rs +++ b/rust/crates/truapi/src/runtime/capabilities/resources.rs @@ -25,15 +25,18 @@ impl ProductRuntimeHost { session: &crate::runtime::authority::AuthoritySession, derivation_index: Option, ) -> Result<(), String> { + let product_id = self.product_id(); let require_session = || { - if self.authority.current_session().as_ref() == Some(session) { + if self + .authority + .session_is_current(session, Some(&product_id)) + { Ok(()) } else { Err("Statement allowance session changed".to_string()) } }; require_session()?; - let product_id = self.product_id(); let service = self.permissions_service(); let request = PermissionAuthorizationRequest::StatementStoreAllowance { derivation_index: derivation_index.clone(), @@ -120,7 +123,10 @@ impl ProductRuntimeHost { err.reason ) })?; - if self.authority.current_session().as_ref() != Some(session) { + if !self + .authority + .session_is_current(session, Some(&self.product_id())) + { return Err("Statement allowance session changed".to_string()); } if status != PermissionAuthorizationStatus::Authorized { @@ -148,8 +154,12 @@ impl ResourceAllocation for ProductRuntimeHost { ))); }; + let product_id = self.product_id(); let require_session = || { - if self.authority.current_session().as_ref() == Some(&session) { + if self + .authority + .session_is_current(&session, Some(&product_id)) + { Ok(()) } else { Err(CallError::HostFailure { @@ -189,12 +199,13 @@ impl ResourceAllocation for ProductRuntimeHost { // A withdrawn call stops waiting on the review and authorizes nothing. let confirmed = until_cancelled( cx, - self.platform.confirm_user_action(UserConfirmationReview::ResourceAllocation( - ResourceAllocationReview { - calling_product_id: self.product_id(), - resources: inner.resources.clone(), - }, - )), + self.platform + .confirm_user_action(UserConfirmationReview::ResourceAllocation( + ResourceAllocationReview { + calling_product_id: product_id.clone(), + resources: inner.resources.clone(), + }, + )), ) .await .map_err(|err| { @@ -259,7 +270,7 @@ impl ResourceAllocation for ProductRuntimeHost { remote_authority_call( &cx, self.authority - .allocate_resources(&cx, &session, self.product_id(), inner), + .allocate_resources(&cx, &session, product_id, inner), ) .await .map(HostRequestResourceAllocationResponse::V1) diff --git a/rust/crates/truapi/src/runtime/capabilities/signing.rs b/rust/crates/truapi/src/runtime/capabilities/signing.rs index 2ed96abe4e..536c8f4938 100644 --- a/rust/crates/truapi/src/runtime/capabilities/signing.rs +++ b/rust/crates/truapi/src/runtime/capabilities/signing.rs @@ -42,10 +42,14 @@ impl Signing for ProductRuntimeHost { v01::HostSignPayloadError::PermissionDenied, )) })?; - self.require_chain_submit(HostSignPayloadError::V1( - v01::HostSignPayloadError::PermissionDenied, - )) - .await?; + until_cancelled( + cx, + self.require_chain_submit(HostSignPayloadError::V1( + v01::HostSignPayloadError::PermissionDenied, + )), + ) + .await + .map_err(|reason| signing_call_error(HostSignPayloadError::V1, reason))??; let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostSignPayloadError::V1( v01::HostSignPayloadError::Rejected, @@ -134,10 +138,14 @@ impl Signing for ProductRuntimeHost { v01::HostCreateTransactionError::PermissionDenied, )) })?; - self.require_chain_submit(HostCreateTransactionError::V1( - v01::HostCreateTransactionError::PermissionDenied, - )) - .await?; + until_cancelled( + cx, + self.require_chain_submit(HostCreateTransactionError::V1( + v01::HostCreateTransactionError::PermissionDenied, + )), + ) + .await + .map_err(|reason| transaction_call_error(HostCreateTransactionError::V1, reason))??; let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostCreateTransactionError::V1( v01::HostCreateTransactionError::Rejected, @@ -249,10 +257,16 @@ impl Signing for ProductRuntimeHost { }), )); } - self.require_chain_submit(HostSignPayloadWithLegacyAccountError::V1( - v01::HostSignPayloadError::PermissionDenied, - )) - .await?; + until_cancelled( + cx, + self.require_chain_submit(HostSignPayloadWithLegacyAccountError::V1( + v01::HostSignPayloadError::PermissionDenied, + )), + ) + .await + .map_err(|reason| { + signing_call_error(HostSignPayloadWithLegacyAccountError::V1, reason) + })??; let confirmed = until_cancelled( cx, self.platform @@ -343,10 +357,16 @@ impl Signing for ProductRuntimeHost { }, )) })?; - self.require_chain_submit(HostCreateTransactionWithLegacyAccountError::V1( - v01::HostCreateTransactionError::PermissionDenied, - )) - .await?; + until_cancelled( + cx, + self.require_chain_submit(HostCreateTransactionWithLegacyAccountError::V1( + v01::HostCreateTransactionError::PermissionDenied, + )), + ) + .await + .map_err(|reason| { + transaction_call_error(HostCreateTransactionWithLegacyAccountError::V1, reason) + })??; let confirmed = until_cancelled( cx, self.platform @@ -431,10 +451,14 @@ impl ProductRuntimeHost { v01::HostSignPayloadError::PermissionDenied, )) })?; - self.require_chain_submit(HostSignRawError::V1( - v01::HostSignPayloadError::PermissionDenied, - )) - .await?; + until_cancelled( + cx, + self.require_chain_submit(HostSignRawError::V1( + v01::HostSignPayloadError::PermissionDenied, + )), + ) + .await + .map_err(|reason| signing_call_error(HostSignRawError::V1, reason))??; let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostSignRawError::V1( v01::HostSignPayloadError::Rejected, @@ -517,10 +541,14 @@ impl ProductRuntimeHost { err.into_host_error(LEGACY_ACCOUNT_UNAVAILABLE_REASON), )) })?; - self.require_chain_submit(HostSignRawWithLegacyAccountError::V1( - v01::HostSignPayloadError::PermissionDenied, - )) - .await?; + until_cancelled( + cx, + self.require_chain_submit(HostSignRawWithLegacyAccountError::V1( + v01::HostSignPayloadError::PermissionDenied, + )), + ) + .await + .map_err(|reason| signing_call_error(HostSignRawWithLegacyAccountError::V1, reason))??; let confirmed = until_cancelled( cx, self.platform diff --git a/rust/crates/truapi/src/runtime/native_chat/background.rs b/rust/crates/truapi/src/runtime/native_chat/background.rs index 8a0ca6e501..e42b2c9284 100644 --- a/rust/crates/truapi/src/runtime/native_chat/background.rs +++ b/rust/crates/truapi/src/runtime/native_chat/background.rs @@ -26,7 +26,7 @@ const RECONCILE_INTERVAL: Duration = Duration::from_secs(5); const MAX_RETRY: Duration = Duration::from_secs(30); pub(super) struct Recovery { - session: Vec, + session_valid: Arc bool + Send + Sync>, active: Arc, abort: AbortHandle, } @@ -56,8 +56,7 @@ impl NativeChatRegistry { return; } if recoveries.get(&key).is_some_and(|recovery| { - recovery.session == context.session.validation_id - && recovery.active.load(Ordering::Acquire) + (recovery.session_valid)() && recovery.active.load(Ordering::Acquire) }) { return; } @@ -71,7 +70,7 @@ impl NativeChatRegistry { recoveries.insert( key, Recovery { - session: context.session.validation_id.clone(), + session_valid: context.session_valid.clone(), active: active.clone(), abort, }, diff --git a/rust/crates/truapi/src/runtime/profile/avatars.rs b/rust/crates/truapi/src/runtime/profile/avatars.rs index 8d9e18d62b..0f2b222807 100644 --- a/rust/crates/truapi/src/runtime/profile/avatars.rs +++ b/rust/crates/truapi/src/runtime/profile/avatars.rs @@ -318,7 +318,7 @@ impl ContactAvatarPlacement { ) .await .unwrap_or_default(); - if authority.current_session().as_ref() == Some(&session) { + if authority.session_is_current(&session, None) { resolved } else { Vec::new() diff --git a/rust/crates/truapi/src/runtime/signing_host.rs b/rust/crates/truapi/src/runtime/signing_host.rs index 37b9d1a120..bc3d211c96 100644 --- a/rust/crates/truapi/src/runtime/signing_host.rs +++ b/rust/crates/truapi/src/runtime/signing_host.rs @@ -99,7 +99,10 @@ use zeroize::Zeroizing; #[derive(Default)] struct LocalGrantState { + // Activation and product revocations are lower bounds on the snapshot clock. activation_generation: u64, + generation: u64, + product_revocations: HashMap, auto_signing_grants: HashSet<([u8; 32], String)>, /// Chat authority the user allowed for this session only, by owner and product. chat_session_grants: HashSet<([u8; 32], String)>, @@ -111,20 +114,55 @@ struct LocalGrantState { impl LocalGrantState { fn advance_activation(&mut self) { - self.activation_generation = self - .activation_generation - .checked_add(1) - .expect("local activation generation exhausted"); + self.advance_generation(); + self.activation_generation = self.generation; + self.product_revocations.clear(); self.auto_signing_grants.clear(); self.chat_session_grants.clear(); self.statement_allowance_keys.clear(); } - fn revoke_product(&mut self, product_id: &str) { - self.activation_generation = self - .activation_generation + fn advance_generation(&mut self) { + self.generation = self + .generation .checked_add(1) - .expect("local activation generation exhausted"); + .expect("local authority generation exhausted"); + } + + fn require_generation(&self, generation: u64) -> Result<(), AuthorityError> { + if generation < self.activation_generation || generation > self.generation { + return Err(AuthorityError::Disconnected); + } + Ok(()) + } + + fn require_product_generation( + &self, + generation: u64, + product_id: &str, + ) -> Result<(), AuthorityError> { + self.require_generation(generation)?; + if generation != self.generation { + let product_id = normalize_product_identifier(product_id).map_err(|error| { + AuthorityError::Unavailable { + reason: error.to_string(), + } + })?; + if self + .product_revocations + .get(&product_id) + .is_some_and(|revoked| *revoked > generation) + { + return Err(AuthorityError::Disconnected); + } + } + Ok(()) + } + + fn revoke_product(&mut self, product_id: &str) { + self.advance_generation(); + self.product_revocations + .insert(product_id.to_owned(), self.generation); self.auto_signing_grants .retain(|(_, granted_product_id)| granted_product_id != product_id); self.chat_session_grants @@ -134,13 +172,11 @@ impl LocalGrantState { fn statement_allowance_key( &self, - activation_generation: u64, + generation: u64, product_id: &str, period: u32, ) -> Result, AuthorityError> { - if self.activation_generation != activation_generation { - return Err(AuthorityError::Disconnected); - } + self.require_product_generation(generation, product_id)?; Ok(self .statement_allowance_keys .get(product_id) @@ -160,14 +196,12 @@ impl LocalGrantState { fn remember_statement_allowance_key( &mut self, - activation_generation: u64, + generation: u64, product_id: String, period: u32, key: StatementStoreAllowanceKey, ) -> Result<(), AuthorityError> { - if self.activation_generation != activation_generation { - return Err(AuthorityError::Disconnected); - } + self.require_product_generation(generation, &product_id)?; self.statement_allowance_keys .insert(product_id, (period, key)); Ok(()) @@ -417,7 +451,7 @@ impl SigningHost { session: &AuthoritySession, product_id: &str, ) -> Result<(), AuthorityError> { - let (_, activation_generation) = self.require_current_session(session)?; + let (_, generation) = self.require_current_product_session(session, product_id)?; let entropy = self.root_entropy()?; let root = derive_root_keypair_from_entropy(&entropy).map_err(product_authority_error)?; let owner = root.public.to_bytes(); @@ -435,9 +469,7 @@ impl SigningHost { .local_grants .lock() .expect("local AutoSigning grant mutex poisoned"); - if state.activation_generation != activation_generation { - return Err(AuthorityError::Disconnected); - } + state.require_product_generation(generation, &product_id)?; state.auto_signing_grants.insert((owner, product_id)); Ok(()) } @@ -448,7 +480,7 @@ impl SigningHost { product_id: &str, policy: OnExistingAllowancePolicy, ) -> Result { - let (_, activation_generation) = self.require_current_session(session)?; + let (_, generation) = self.require_current_product_session(session, product_id)?; let allocation = sso_responder::allocate_statement_store_allowance( &self.services, self, @@ -462,7 +494,7 @@ impl SigningHost { .lock() .expect("local AutoSigning grant mutex poisoned") .remember_statement_allowance_key( - activation_generation, + generation, product_id.to_string(), allocation.period, key.clone(), @@ -472,7 +504,7 @@ impl SigningHost { fn has_auto_signing_grant( &self, - activation_generation: u64, + generation: u64, owner: [u8; 32], calling_product_id: &str, account_product_id: &str, @@ -491,7 +523,9 @@ impl SigningHost { .local_grants .lock() .expect("local AutoSigning grant mutex poisoned"); - state.activation_generation == activation_generation + state + .require_product_generation(generation, &calling_product_id) + .is_ok() && state .auto_signing_grants .contains(&(owner, calling_product_id)) @@ -609,7 +643,7 @@ impl SigningHost { let session = self.session_state.current()?; Some(AuthoritySession::from_session_info( &session, - local_session_validation_id(&session, state.activation_generation), + local_session_validation_id(&session, state.generation), )) } @@ -625,12 +659,22 @@ impl SigningHost { .session_state .current() .ok_or(AuthorityError::Disconnected)?; - if local_session_validation_id(¤t, state.activation_generation) - != session.validation_id - { - return Err(AuthorityError::Disconnected); - } - Ok((current, state.activation_generation)) + let generation = local_session_generation(¤t, &session.validation_id)?; + state.require_generation(generation)?; + Ok((current, generation)) + } + + fn require_current_product_session( + &self, + session: &AuthoritySession, + product_id: &str, + ) -> Result<(SessionInfo, u64), AuthorityError> { + let (current, generation) = self.require_current_session(session)?; + self.local_grants + .lock() + .expect("local AutoSigning grant mutex poisoned") + .require_product_generation(generation, product_id)?; + Ok((current, generation)) } fn native_chat_context( @@ -648,7 +692,8 @@ impl SigningHost { .lock() .expect("local AutoSigning grant mutex poisoned"); session_state.current().is_some_and(|current| { - local_session_validation_id(¤t, grants.activation_generation) == validation_id + local_session_generation(¤t, &validation_id) + .is_ok_and(|generation| grants.require_generation(generation).is_ok()) }) }); let local_grants = self.local_grants.clone(); @@ -672,6 +717,40 @@ impl SigningHost { }) } + fn native_chat_product_context( + &self, + session: &AuthoritySession, + product_id: &str, + ) -> Result { + let (_, generation) = self.require_current_product_session(session, product_id)?; + let mut context = self.native_chat_context(session)?; + let session_valid = context.session_valid.clone(); + let grants = self.local_grants.clone(); + let product_id = product_id.to_owned(); + context.session_valid = Arc::new(move || { + session_valid() + && grants + .lock() + .expect("local AutoSigning grant mutex poisoned") + .require_product_generation(generation, &product_id) + .is_ok() + }); + Ok(context) + } + + fn require_signing_session( + &self, + session: &AuthoritySession, + caller: Option<&str>, + account_product: Option<&str>, + ) -> Result<(), AuthorityError> { + self.require_current_session(session)?; + for product in [caller, account_product].into_iter().flatten() { + self.require_current_product_session(session, product)?; + } + Ok(()) + } + /// Read the current wallet's authenticated native Chat roster for the host shell. pub async fn get_native_chat_contacts( &self, @@ -705,7 +784,7 @@ impl SigningHost { session: &AuthoritySession, handle: &v01::ProductAccountId, ) -> Result, RingVrfError> { - self.require_current_session(session)?; + self.require_current_product_session(session, &handle.dot_ns_identifier)?; let root = self.root_entropy()?; derive_ring_vrf_entropy(&root, &handle.dot_ns_identifier, &handle.derivation_index) .map(Zeroizing::new) @@ -815,7 +894,7 @@ impl SigningHost { session: &AuthoritySession, handle: &v01::ProductAccountId, ) -> Result, RingVrfError> { - self.require_current_session(session)?; + self.require_current_product_session(session, &handle.dot_ns_identifier)?; self.ring_vrf_registry .entry(session.public_key, handle) .await @@ -956,17 +1035,19 @@ impl SigningHost { request: v01::HostAccountSignVrfRequest, authenticated_caller: bool, ) -> Result { - self.require_current_session(session)?; + self.require_current_product_session(session, &calling_product_id)?; + self.require_current_product_session(session, &request.account.dot_ns_identifier)?; validate_vrf_transcript(&request).map_err(|reason| AuthorityError::Unknown { reason })?; let keypair = self.product_keypair(&request.account)?; - let (current, activation_generation) = self.require_current_session(session)?; + let (current, generation) = + self.require_current_product_session(session, &calling_product_id)?; let granted = authenticated_caller && super::authority::is_blessed_owner( &calling_product_id, &request.account.dot_ns_identifier, ) || self.has_auto_signing_grant( - activation_generation, + generation, current.public_key, &calling_product_id, &request.account.dot_ns_identifier, @@ -976,7 +1057,7 @@ impl SigningHost { cx, self.platform .confirm_user_action(UserConfirmationReview::SignVrf(SignVrfReview { - calling_product_id, + calling_product_id: calling_product_id.clone(), request: request.clone(), })), ) @@ -988,6 +1069,8 @@ impl SigningHost { return Err(AuthorityError::Rejected); } } + self.require_current_product_session(session, &calling_product_id)?; + self.require_current_product_session(session, &request.account.dot_ns_identifier)?; let (pre_output, proof) = crate::dynamic_vrf::sign_dynamic_vrf( &keypair, &request.transcript_label, @@ -1056,17 +1139,34 @@ impl SigningHost { #[async_trait::async_trait] impl ProductAuthority for SigningHost { fn chat_session_granted(&self, session: &AuthoritySession, product_id: &str) -> bool { - self.local_grants + let Ok((_, generation)) = self.require_current_session(session) else { + return false; + }; + let state = self + .local_grants .lock() - .expect("local AutoSigning grant mutex poisoned") - .chat_session_grants - .contains(&(session.public_key, product_id.to_owned())) + .expect("local AutoSigning grant mutex poisoned"); + state + .require_product_generation(generation, product_id) + .is_ok() + && state + .chat_session_grants + .contains(&(session.public_key, product_id.to_owned())) } fn current_session(&self) -> Option { self.current_local_session() } + fn session_is_current(&self, session: &AuthoritySession, product_id: Option<&str>) -> bool { + match product_id { + Some(product_id) => self + .require_current_product_session(session, product_id) + .is_ok(), + None => self.require_current_session(session).is_ok(), + } + } + async fn refresh_session_identity(&self) -> Option { let context = self.local_identity_context().ok()?; if let Err(error) = self.refresh_local_identity(&context.activation_id).await { @@ -1112,7 +1212,7 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, product_id: String, ) -> Result<[u8; 32], AuthorityError> { - self.require_current_session(session)?; + self.require_current_product_session(session, &product_id)?; let product_id = normalize_product_identifier(&product_id).map_err(|err| { AuthorityError::Unavailable { reason: err.to_string(), @@ -1146,10 +1246,12 @@ impl ProductAuthority for SigningHost { // `grant_auto_signing` refuses to record a grant whose owner is not // the session's own key, so the session carries the owner a grant can // be keyed on and no root derivation is needed to answer this. - let (current, activation_generation) = self.require_current_session(session)?; + let (current, generation) = + self.require_current_product_session(session, calling_product_id)?; + self.require_current_product_session(session, &account.dot_ns_identifier)?; if super::authority::is_blessed_owner(calling_product_id, &account.dot_ns_identifier) || self.has_auto_signing_grant( - activation_generation, + generation, current.public_key, calling_product_id, &account.dot_ns_identifier, @@ -1176,19 +1278,26 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - _calling_product_id: Option<&str>, + calling_product_id: Option<&str>, request: SignPayloadAuthorityRequest, ) -> Result { self.require_current_session(session)?; - let (keypair, payload) = match request { - SignPayloadAuthorityRequest::Product(request) => { - (self.product_keypair(&request.account)?, request.payload) - } + let (keypair, payload, product_id) = match request { + SignPayloadAuthorityRequest::Product(request) => ( + self.product_keypair(&request.account)?, + request.payload, + request.account.dot_ns_identifier, + ), SignPayloadAuthorityRequest::LegacyAccount { product_account, request, - } => (self.product_keypair(&product_account)?, request.payload), + } => ( + self.product_keypair(&product_account)?, + request.payload, + product_account.dot_ns_identifier, + ), }; + self.require_signing_session(session, calling_product_id, Some(&product_id))?; Ok(sign_extrinsic_payload(&keypair, payload)?) } @@ -1196,14 +1305,16 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - _calling_product_id: Option<&str>, + calling_product_id: Option<&str>, request: SignRawAuthorityRequest, watermarked: bool, ) -> Result { - let (keypair, payload) = match request { - SignRawAuthorityRequest::Product(request) => { - (self.product_keypair(&request.account)?, request.payload) - } + let (keypair, payload, product_id) = match request { + SignRawAuthorityRequest::Product(request) => ( + self.product_keypair(&request.account)?, + request.payload, + Some(request.account.dot_ns_identifier), + ), SignRawAuthorityRequest::LegacyAccount { account, request } => { let keypair = self.identity_keypair()?; if keypair.public.to_bytes() != account { @@ -1213,10 +1324,10 @@ impl ProductAuthority for SigningHost { .to_string(), }); } - (keypair, request.payload) + (keypair, request.payload, None) } }; - self.require_current_session(session)?; + self.require_signing_session(session, calling_product_id, product_id.as_deref())?; let message = raw_payload_bytes(payload, watermarked)?; let signature = keypair .secret @@ -1232,11 +1343,20 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - _calling_product_id: Option<&str>, + calling_product_id: Option<&str>, request: CreateTransactionAuthorityRequest, ) -> Result { - self.require_current_session(session)?; - match request { + let account_product = match &request { + CreateTransactionAuthorityRequest::Product(payload) => { + Some(payload.signer.dot_ns_identifier.as_str()) + } + CreateTransactionAuthorityRequest::LegacyAccount { + product_account, .. + } => Some(product_account.dot_ns_identifier.as_str()), + CreateTransactionAuthorityRequest::IdentityAccount(_) => None, + }; + self.require_signing_session(session, calling_product_id, account_product)?; + let response = match &request { CreateTransactionAuthorityRequest::Product(payload) => { // The product account is authoritative and caller-scoping is // enforced upstream, so the derived key defines the signer. @@ -1298,7 +1418,9 @@ impl ProductAuthority for SigningHost { .await .map_err(AuthorityError::from) } - } + }; + self.require_signing_session(session, calling_product_id, account_product)?; + response } async fn account_alias( @@ -1307,7 +1429,7 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, request: ProductRequest, ) -> Result { - self.require_current_session(session)?; + self.require_current_product_session(session, &request.calling_product_id)?; // A `context` grant covers this. RFC-0024 defines the scope as "acting // as the granting product's account: reading it and the identity that // follows from it", and the contextual alias is that identity: it and @@ -1384,6 +1506,8 @@ impl ProductAuthority for SigningHost { self.ring_resolver .validate(&request.payload.ring_location) .await?; + self.require_current_product_session(session, &request.calling_product_id)?; + self.require_current_product_session(session, &key_handle.dot_ns_identifier)?; let context = development_context_bytes(&request.payload.context); let alias = vrf.alias(&entropy, &context)?; Ok(v01::ContextualAlias { @@ -1398,7 +1522,7 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, request: ProductRequest, ) -> Result { - self.require_current_session(session)?; + self.require_current_product_session(session, &request.calling_product_id)?; let (key_handle, access) = self .require_ring_vrf_key_access(&request.calling_product_id, &request.payload.key_handle) .await?; @@ -1428,7 +1552,8 @@ impl ProductAuthority for SigningHost { .await?; // Reject a stale request if the local session disconnected or changed // while its chain snapshot was being resolved. - self.require_current_session(session)?; + self.require_current_product_session(session, &request.calling_product_id)?; + self.require_current_product_session(session, &key_handle.dot_ns_identifier)?; let context = development_context_bytes(&request.payload.context); let (proof, alias) = create_proof( &vrf, @@ -1454,7 +1579,7 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, request: ProductRequest, ) -> Result<[u8; 32], RingVrfError> { - self.require_current_session(session)?; + self.require_current_product_session(session, &request.calling_product_id)?; self.ring_resolver.validate(&request.payload.ring).await?; let handle = v01::ProductAccountId { @@ -1467,9 +1592,11 @@ impl ProductAuthority for SigningHost { }; let entropy = self.ring_vrf_entropy(session, &handle)?; let public_key = vrf::load().await?.member(&entropy)?; + self.require_current_product_session(session, &request.calling_product_id)?; self.ring_vrf_registry .register(session.public_key, handle, request.payload.ring, public_key) .await?; + self.require_current_product_session(session, &request.calling_product_id)?; Ok(public_key) } @@ -1479,7 +1606,7 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, request: ProductRequest, ) -> Result, RingVrfError> { - self.require_current_session(session)?; + self.require_current_product_session(session, &request.calling_product_id)?; let owner = normalize_product_identifier(&request.payload.owner).map_err(|err| { RingVrfError::Unknown { reason: err.to_string(), @@ -1519,7 +1646,8 @@ impl ProductAuthority for SigningHost { .ring_vrf_registry .owner_entries(session.public_key, &owner) .await?; - self.require_current_session(session)?; + self.require_current_product_session(session, &caller)?; + self.require_current_product_session(session, &owner)?; if request.payload.disclosure == v01::RingVrfKeyDisclosure::Anonymized { for entry in &mut entries { entry.public_key = None; @@ -1534,7 +1662,7 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, request: ProductRequest, ) -> Result, RingVrfError> { - self.require_current_session(session)?; + self.require_current_product_session(session, &request.calling_product_id)?; let (key_handle, _access) = self .require_ring_vrf_key_access(&request.calling_product_id, &request.payload.key_handle) .await?; @@ -1542,6 +1670,8 @@ impl ProductAuthority for SigningHost { let entropy = self .resolve_registered_ring_vrf_key(&vrf, session, &key_handle) .await?; + self.require_current_product_session(session, &request.calling_product_id)?; + self.require_current_product_session(session, &key_handle.dot_ns_identifier)?; vrf.sign(&entropy, &request.payload.message) } @@ -1552,7 +1682,8 @@ impl ProductAuthority for SigningHost { request: ProductDeviceChatAuthorityRequest, ) -> Result { - let (_, activation_generation) = self.require_current_session(session)?; + let (_, generation) = + self.require_current_product_session(session, &request.calling_product_id)?; let calling_product_id = normalize_product_identifier(&request.calling_product_id) .map_err(|_| { ProductDeviceChatAuthorityError::Domain( @@ -1564,16 +1695,14 @@ impl ProductAuthority for SigningHost { .local_grants .lock() .expect("local AutoSigning grant mutex poisoned"); - // A revocation or reactivation since the session check advanced the - // generation; the grant must not outlive it. - if state.activation_generation != activation_generation { - return Err(ProductDeviceChatAuthorityError::Disconnected); - } + state + .require_product_generation(generation, &calling_product_id) + .map_err(ProductDeviceChatAuthorityError::from)?; state .chat_session_grants .insert((session.public_key, calling_product_id.clone())); } - let context = self.native_chat_context(session)?; + let context = self.native_chat_product_context(session, &calling_product_id)?; self.native_chat .execute(context, calling_product_id, request.operation) .await @@ -1586,12 +1715,12 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, request: PaymentTopUpRequest, ) -> Result<(), PaymentTopUpAuthorityError> { - self.require_current_session(session)?; + self.require_current_product_session(session, &request.calling_product_id)?; let product = normalize_product_identifier(&request.calling_product_id).map_err(|_| { PaymentTopUpAuthorityError::Domain(v01::HostPaymentTopUpError::InvalidSource) })?; let context = self - .native_chat_context(session) + .native_chat_product_context(session, &product) .map_err(|error| match error { ProductDeviceChatAuthorityError::Disconnected => AuthorityError::Disconnected, _ => AuthorityError::Unavailable { @@ -1617,7 +1746,7 @@ impl ProductAuthority for SigningHost { product_id: &str, peer_identity: [u8; 32], ) -> Option { - let context = self.native_chat_context(session).ok()?; + let context = self.native_chat_product_context(session, product_id).ok()?; self.native_chat .contact_username(&context, product_id, peer_identity) .await @@ -1630,7 +1759,7 @@ impl ProductAuthority for SigningHost { product_id: String, request: v01::HostRequestResourceAllocationRequest, ) -> Result { - self.require_current_session(session)?; + self.require_current_product_session(session, &product_id)?; #[cfg(feature = "test-host")] if self .grant_allowances_unchecked @@ -1734,7 +1863,7 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, product_id: String, ) -> Result { - let (_, activation_generation) = self.require_current_session(session)?; + let (_, generation) = self.require_current_product_session(session, &product_id)?; #[cfg(feature = "test-host")] self.refuse_withheld(&v01::AllocatableResource::StatementStoreAllowance)?; let period = statement_allowance::slot::current_period( @@ -1745,7 +1874,7 @@ impl ProductAuthority for SigningHost { .local_grants .lock() .expect("local AutoSigning grant mutex poisoned") - .statement_allowance_key(activation_generation, &product_id, period)? + .statement_allowance_key(generation, &product_id, period)? { return Ok(key.clone()); } @@ -1771,7 +1900,7 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, product_id: String, ) -> Result { - self.require_current_session(session)?; + self.require_current_product_session(session, &product_id)?; #[cfg(feature = "test-host")] self.refuse_withheld(&v01::AllocatableResource::BulletinAllowance)?; let secret = sso_responder::allocate_bulletin_allowance( @@ -1792,7 +1921,7 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, product_id: String, ) -> Result { - self.require_current_session(session)?; + self.require_current_product_session(session, &product_id)?; #[cfg(feature = "test-host")] self.refuse_withheld(&v01::AllocatableResource::BulletinAllowance)?; let secret = sso_responder::allocate_bulletin_allowance( @@ -1811,12 +1940,17 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - _calling_product_id: Option<&str>, + calling_product_id: Option<&str>, account: v01::ProductAccountId, payload: Vec, ) -> Result<[u8; 64], AuthorityError> { - self.require_current_session(session)?; + self.require_current_product_session(session, &account.dot_ns_identifier)?; let keypair = self.product_keypair(&account)?; + self.require_signing_session( + session, + calling_product_id, + Some(&account.dot_ns_identifier), + )?; Ok(keypair .secret .sign_simple(SR25519_SIGNING_CONTEXT, &payload, &keypair.public) @@ -1829,7 +1963,7 @@ impl ProductAuthority for SigningHost { product_id: &str, context: &[u8], ) -> Result<[u8; 32], AuthorityError> { - self.require_current_session(session)?; + self.require_current_product_session(session, product_id)?; let entropy = self.root_entropy()?; derive_product_entropy(&entropy, product_id, context).map_err(|err| { AuthorityError::Unknown { @@ -1850,13 +1984,27 @@ impl ProductAuthority for SigningHost { } } -fn local_session_validation_id(session: &SessionInfo, activation_generation: u64) -> Vec { +fn local_session_validation_id(session: &SessionInfo, generation: u64) -> Vec { let mut id = authority_session_validation_id(session); - id.extend_from_slice(b":activation:"); - id.extend_from_slice(&activation_generation.to_le_bytes()); + id.extend_from_slice(b":generation:"); + id.extend_from_slice(&generation.to_le_bytes()); id } +fn local_session_generation( + current: &SessionInfo, + validation_id: &[u8], +) -> Result { + let encoded = validation_id + .last_chunk::<8>() + .ok_or(AuthorityError::Disconnected)?; + let generation = u64::from_le_bytes(*encoded); + if local_session_validation_id(current, generation) != validation_id { + return Err(AuthorityError::Disconnected); + } + Ok(generation) +} + fn product_authority_error(err: ProductAccountError) -> AuthorityError { AuthorityError::Unavailable { reason: err.to_string(), @@ -1921,6 +2069,43 @@ mod tests { const ENTROPY: [u8; 16] = [0xAB; 16]; + #[derive(Clone, Copy, Debug)] + enum AuthorityChange { + ClearOtherProduct, + ClearProduct, + Reactivate, + ReplaceWallet, + Disconnect, + } + + impl AuthorityChange { + const ALL: [Self; 5] = [ + Self::ClearOtherProduct, + Self::ClearProduct, + Self::Reactivate, + Self::ReplaceWallet, + Self::Disconnect, + ]; + + async fn apply(self, authority: &SigningHostRole) { + match self { + Self::ClearOtherProduct => { + authority.clear_product_state("other.dot").await.unwrap() + } + Self::ClearProduct => authority.clear_product_state(" MYAPP.DOT ").await.unwrap(), + Self::Reactivate => authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(), + Self::ReplaceWallet => authority + .activate_local_session(vec![0xCD; 16]) + .await + .unwrap(), + Self::Disconnect => authority.disconnect().await, + } + } + } + #[derive(Clone)] struct StubRingResolver { collection: [u8; 32], @@ -4213,6 +4398,12 @@ mod tests { .grant_auto_signing(&stale_session, "other.dot") .expect("other product grant succeeds"); let context = authority.native_chat_context(&stale_session).unwrap(); + let product_context = authority + .native_chat_product_context(&stale_session, "myapp.dot") + .unwrap(); + let other_context = authority + .native_chat_product_context(&stale_session, "other.dot") + .unwrap(); let wallet = futures::executor::block_on(authority.native_chat.wallet(&context)).unwrap(); let custody = Arc::downgrade(&wallet); drop(wallet); @@ -4223,6 +4414,18 @@ mod tests { custody.upgrade().is_some(), "product clear must preserve wallet custody" ); + assert_eq!( + ( + product_context.require_current(), + other_context.require_current(), + context.require_current(), + ), + ( + Err(truapi::latest::HostProductDeviceChatError::NotConnected), + Ok(()), + Ok(()), + ), + ); let current_session = authority.current_session().expect("session remains active"); let (_, current_generation) = authority @@ -4244,6 +4447,77 @@ mod tests { authority.grant_auto_signing(&stale_session, "myapp.dot"), Err(AuthorityError::Disconnected) )); + authority + .grant_auto_signing(&stale_session, "other.dot") + .expect("another product's snapshot remains authorized"); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())).unwrap(); + assert_eq!( + (other_context.require_current(), context.require_current()), + ( + Err(truapi::latest::HostProductDeviceChatError::NotConnected), + Err(truapi::latest::HostProductDeviceChatError::NotConnected), + ), + ); + } + + #[test] + fn product_review_revalidation_preserves_unrelated_work_and_fences_revocation() { + use futures::FutureExt; + + for change in AuthorityChange::ALL { + let (release, gate) = futures::channel::oneshot::channel(); + let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: true, + ..Default::default() + }); + *platform + .resource_allocation_confirmation_gate + .lock() + .unwrap() = Some(gate); + let (services, authority) = signing_runtime_with_platform(platform); + futures::executor::block_on(async { + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let runtime = product_runtime(services, authority.clone()); + let cx = CallContext::default(); + let allocation = ResourceAllocation::request( + &runtime, + &cx, + HostRequestResourceAllocationRequest::V1( + v01::HostRequestResourceAllocationRequest { + resources: vec![v01::AllocatableResource::AutoSigning], + }, + ), + ); + futures::pin_mut!(allocation); + assert!(allocation.as_mut().now_or_never().is_none()); + change.apply(&authority).await; + release.send(()).unwrap(); + let result = allocation.await; + if matches!(change, AuthorityChange::ClearOtherProduct) { + assert_eq!( + result.unwrap(), + HostRequestResourceAllocationResponse::V1( + v01::HostRequestResourceAllocationResponse { + outcomes: vec![v01::AllocationOutcome::Allocated], + }, + ), + ); + } else { + assert!(result.is_err(), "{change:?}: {result:?}"); + assert!( + authority + .local_grants + .lock() + .unwrap() + .auto_signing_grants + .is_empty() + ); + } + }); + } } #[test] diff --git a/rust/crates/truapi/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi/src/runtime/signing_host/sso_responder.rs index b31dcb4813..883189e604 100644 --- a/rust/crates/truapi/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi/src/runtime/signing_host/sso_responder.rs @@ -958,7 +958,7 @@ pub async fn allocate_statement_store_allowance( #[cfg(any(test, not(target_arch = "wasm32")))] use super::allowance_renewal::{self, StatementRenewalTarget}; - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; let entropy = signing_host.root_entropy()?; let allowance = derive_sr25519_hard_path(&entropy, &["allowance", "statement-store", product_id])?; @@ -993,7 +993,7 @@ pub async fn allocate_statement_store_allowance( { warn!(%product_id, %reason, "failed to record statement-store renewal target"); } - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; Ok(StatementStoreAllocation { secret: allowance.secret.to_bytes().to_vec(), period, @@ -1008,7 +1008,7 @@ pub(super) async fn allocate_product_statement_store_allowance( derivation_index: &v01::DerivationIndex, policy: OnExistingAllowancePolicy, ) -> Result<(), AllowanceAllocationError> { - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; #[cfg(feature = "test-host")] if signing_host.grants_allowances_unchecked() { return Ok(()); @@ -1038,7 +1038,7 @@ async fn register_statement_store_target( register_statement_account_pooled, scan_collections, }; - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; let candidates = signing_host.reserved_person_collection_candidates(session)?; let client = services .statement_store @@ -1064,7 +1064,7 @@ async fn register_statement_store_target( reuse_existing, ) .await?; - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; if let Some((collection, seq)) = allocated_in(&scans) { debug!( %product_id, @@ -1082,7 +1082,7 @@ async fn register_statement_store_target( resource: "statement-store", }); } - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; let outcome = register_statement_account_pooled( rpc, &chain.metadata, @@ -1128,7 +1128,7 @@ async fn register_statement_store_target( } } } - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; Ok(period) } @@ -1145,7 +1145,7 @@ pub async fn allocate_bulletin_allowance( wait_bulletin_authorization, }; - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; let entropy = signing_host.root_entropy()?; let allowance = derive_sr25519_hard_path(&entropy, &["allowance", "bulletin", product_id])?; #[cfg(feature = "test-host")] @@ -1168,7 +1168,7 @@ pub async fn allocate_bulletin_allowance( if matches!(policy, OnExistingAllowancePolicy::Ignore) && current_allowance.is_some_and(|allowance| allowance.available()) { - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; return Ok(allowance.secret.to_bytes().to_vec()); } @@ -1203,7 +1203,7 @@ pub async fn allocate_bulletin_allowance( current_unix_secs()?, period_duration, )?; - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; let outcome = claim_long_term_storage( statement_allowance::LongTermStorageClaim { rpc: people_rpc, @@ -1215,7 +1215,11 @@ pub async fn allocate_bulletin_allowance( period, ring: &membership.ring, }, - || signing_host.require_current_session(session).is_ok(), + || { + signing_host + .require_current_product_session(session, product_id) + .is_ok() + }, ) .await?; let statement_allowance::LongTermStorageOutcome::Claimed { @@ -1244,7 +1248,7 @@ pub async fn allocate_bulletin_allowance( remained_transactions = authorization.remained_transactions, "Bulletin authorization visible" ); - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; Ok(allowance.secret.to_bytes().to_vec()) } @@ -1272,7 +1276,7 @@ pub async fn allocate_smart_contract_allowance( use crate::host_logic::features; use crate::runtime::statement_allowance::{self, ChainClient, find_including_rings, pgas}; - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; // PGAS credits the product account the caller named. let target = signing_host @@ -1309,7 +1313,7 @@ pub async fn allocate_smart_contract_allowance( && pgas::holds_a_full_claim(asset_hub_client.rpc(), &asset_hub.metadata, &target).await? { debug!(%product_id, "PGAS allowance already funded; leaving it alone"); - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; return Ok(()); } let network_suffix = @@ -1331,7 +1335,7 @@ pub async fn allocate_smart_contract_allowance( .next() .ok_or(AllowanceAllocationError::MissingPersonhoodMembership { resource: "PGAS" })?; - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; let outcome = pgas::claim_pgas(pgas::PgasClaim { asset_hub_rpc: asset_hub_client.rpc(), asset_hub: &asset_hub, @@ -1351,7 +1355,7 @@ pub async fn allocate_smart_contract_allowance( block = %outcome.block_hash, "claimed PGAS allowance" ); - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; Ok(()) } diff --git a/rust/crates/truapi/src/runtime/signing_host/sso_service.rs b/rust/crates/truapi/src/runtime/signing_host/sso_service.rs index ed35588a1c..4337bd8113 100644 --- a/rust/crates/truapi/src/runtime/signing_host/sso_service.rs +++ b/rust/crates/truapi/src/runtime/signing_host/sso_service.rs @@ -463,12 +463,12 @@ impl SigningHostSsoService { } self.signing_host - .require_current_session(&cx.session) + .require_current_product_session(&cx.session, &request.calling_product_id) .map_err(|err| err.to_string())?; let mut outcomes = Vec::with_capacity(request.resources.len()); for resource in request.resources { self.signing_host - .require_current_session(&cx.session) + .require_current_product_session(&cx.session, &request.calling_product_id) .map_err(|err| err.to_string())?; if cx.call.cancel().is_cancelled() { return Err(WITHDRAWN.to_string()); @@ -482,7 +482,7 @@ impl SigningHostSsoService { ) .await; self.signing_host - .require_current_session(&cx.session) + .require_current_product_session(&cx.session, &request.calling_product_id) .map_err(|err| err.to_string())?; outcomes.push(outcome.unwrap_or_else(|err| { let reason = err.to_string(); @@ -667,7 +667,7 @@ impl SigningHostSsoService { }; self.signing_host - .require_current_session(&cx.session) + .require_current_product_session(&cx.session, &request.calling_product_id) .map_err(|_| WireError::V1(api::HostProductDeviceChatError::NotConnected))?; let calling_product_id = normalize_product_identifier(&request.calling_product_id) .map_err(|_| WireError::V1(api::HostProductDeviceChatError::InvalidRequest))?; diff --git a/rust/crates/truapi/src/runtime/signing_host/tests/allowance_keys.rs b/rust/crates/truapi/src/runtime/signing_host/tests/allowance_keys.rs index a8411654f6..8b81217a2e 100644 --- a/rust/crates/truapi/src/runtime/signing_host/tests/allowance_keys.rs +++ b/rust/crates/truapi/src/runtime/signing_host/tests/allowance_keys.rs @@ -25,7 +25,11 @@ fn chain_with_allocated_slot() -> Arc { let allowance = derive_sr25519_hard_path(&ENTROPY, &["allowance", "statement-store", PRODUCT_ID]) .expect("allowance derivation succeeds"); - let slot_entry = (allowance.public.to_bytes(), 0u32, 0u64).encode(); + chain_with_allocated_target(allowance.public.to_bytes()) +} + +fn chain_with_allocated_target(target: [u8; 32]) -> Arc { + let slot_entry = (target, 0u32, 0u64).encode(); let people_row = slot::testing::slot_row( derive_full_person_ring_vrf_entropy(&ENTROPY, TEST_NETWORK_SUFFIX), TEST_NETWORK_SUFFIX.as_bytes(), @@ -134,7 +138,7 @@ fn current_generation(signing_host: &SigningHostRole) -> u64 { .local_grants .lock() .expect("local AutoSigning grant mutex poisoned") - .activation_generation + .generation } fn remembered(signing_host: &SigningHostRole, product_id: &str, period: u32) -> Option<[u8; 64]> { @@ -143,7 +147,7 @@ fn remembered(signing_host: &SigningHostRole, product_id: &str, period: u32) -> .lock() .expect("local AutoSigning grant mutex poisoned"); state - .statement_allowance_key(state.activation_generation, product_id, period) + .statement_allowance_key(state.generation, product_id, period) .expect("the generation is current") .map(|key| key.secret) } @@ -214,6 +218,174 @@ fn clearing_a_product_forgets_only_its_key() { ); } +#[test] +fn clearing_another_product_preserves_in_flight_allowance_authority() { + for previously_bound in [false, true] { + let platform = chain_with_allocated_slot(); + let signing_host = active_signing_host(platform.clone()); + futures::executor::block_on(async { + if previously_bound { + signing_host.clear_product_state("other.dot").await.unwrap(); + } + let (release, gate) = futures::channel::oneshot::channel(); + *platform.rpc_method_responses_gate.lock().unwrap() = Some(gate); + let session = signing_host.current_session().unwrap(); + let cx = CallContext::default(); + let allocation = + signing_host.statement_store_allowance_key(&cx, &session, PRODUCT_ID.to_string()); + futures::pin_mut!(allocation); + assert!(allocation.as_mut().now_or_never().is_none()); + signing_host.clear_product_state("other.dot").await.unwrap(); + release.send(()).unwrap(); + let key = futures::select! { + result = allocation.fuse() => result, + _ = futures_timer::Delay::new(std::time::Duration::from_secs(30)).fuse() => { + panic!("the allocation blocked after releasing the chain response") + } + } + .expect("clearing another product must not disconnect this allocation"); + let expected = + derive_sr25519_hard_path(&ENTROPY, &["allowance", "statement-store", PRODUCT_ID]) + .unwrap(); + assert_eq!(key.public_key, expected.public.to_bytes()); + }); + } +} + +#[test] +fn product_and_account_revocation_fence_in_flight_allowance_keys() { + for change in [ + AuthorityChange::ClearProduct, + AuthorityChange::Reactivate, + AuthorityChange::Disconnect, + ] { + let platform = chain_with_allocated_slot(); + let signing_host = active_signing_host(platform.clone()); + futures::executor::block_on(async { + let (release, gate) = futures::channel::oneshot::channel(); + *platform.rpc_method_responses_gate.lock().unwrap() = Some(gate); + let session = signing_host.current_session().unwrap(); + let cx = CallContext::default(); + let allocation = + signing_host.statement_store_allowance_key(&cx, &session, PRODUCT_ID.to_string()); + futures::pin_mut!(allocation); + assert!(allocation.as_mut().now_or_never().is_none()); + change.apply(&signing_host).await; + release.send(()).unwrap(); + let result = futures::select! { + result = allocation.fuse() => result, + _ = futures_timer::Delay::new(std::time::Duration::from_secs(30)).fuse() => { + panic!("the allocation blocked after releasing the chain response") + } + }; + assert!( + matches!(result, Err(AuthorityError::Disconnected)), + "{change:?}: {result:?}" + ); + assert_eq!( + remembered( + &signing_host, + PRODUCT_ID, + slot::current_period(crate::unix_time::current_unix_secs()) + ), + None + ); + }); + } +} + +#[test] +fn raw_product_allowance_revalidates_only_its_product_and_account() { + use super::super::sso_responder::allocate_product_statement_store_allowance; + use crate::host_internal::sso_messages::OnExistingAllowancePolicy; + + for change in AuthorityChange::ALL { + let account = v01::ProductAccountId { + dot_ns_identifier: PRODUCT_ID.to_string(), + derivation_index: v01::DerivationIndex::Raw([0x44; 32]), + }; + let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); + let target = derive_product_keypair(&root, PRODUCT_ID, [0x44; 32]) + .unwrap() + .public + .to_bytes(); + let platform = chain_with_allocated_target(target); + let (services, signing_host) = signing_runtime_with_platform(platform.clone()); + futures::executor::block_on(async { + signing_host + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let (release, gate) = futures::channel::oneshot::channel(); + *platform.rpc_method_responses_gate.lock().unwrap() = Some(gate); + let session = signing_host.current_session().unwrap(); + let allocation = allocate_product_statement_store_allowance( + &services, + &signing_host, + &session, + PRODUCT_ID, + &account.derivation_index, + OnExistingAllowancePolicy::Ignore, + ); + futures::pin_mut!(allocation); + assert!(allocation.as_mut().now_or_never().is_none()); + change.apply(&signing_host).await; + release.send(()).unwrap(); + let result = futures::select! { + result = allocation.fuse() => result, + _ = futures_timer::Delay::new(std::time::Duration::from_secs(30)).fuse() => { + panic!("the raw allocation blocked after releasing the chain response") + } + } + .map_err(|error| error.into_authority_error()); + let expected = if matches!(change, AuthorityChange::ClearOtherProduct) { + Ok(()) + } else { + Err(AuthorityError::Disconnected) + }; + assert_eq!(result, expected, "{change:?}"); + }); + } +} + +#[test] +fn product_revocation_fences_allowance_cache_commits() { + let signing_host = active_signing_host(Arc::new(StubPlatform::default())); + let stale_generation = current_generation(&signing_host); + futures::executor::block_on(signing_host.clear_product_state(PRODUCT_ID)).unwrap(); + let result = signing_host + .local_grants + .lock() + .unwrap() + .remember_statement_allowance_key( + stale_generation, + PRODUCT_ID.to_string(), + PERIOD, + secret_key(), + ); + assert_eq!( + (result, remembered(&signing_host, PRODUCT_ID, PERIOD)), + (Err(AuthorityError::Disconnected), None) + ); + + let fresh_generation = current_generation(&signing_host); + futures::executor::block_on(signing_host.clear_product_state("unrelated.dot")).unwrap(); + let result = signing_host + .local_grants + .lock() + .unwrap() + .remember_statement_allowance_key( + fresh_generation, + PRODUCT_ID.to_string(), + PERIOD, + secret_key(), + ); + assert_eq!( + (result, remembered(&signing_host, PRODUCT_ID, PERIOD)), + (Ok(()), Some(SECRET)) + ); +} + #[test] fn a_replaced_session_is_not_served_the_new_sessions_key() { let signing_host = active_signing_host(Arc::new(StubPlatform::default())); diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index f658dab43f..dec7794b93 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -6,6 +6,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use crate::platform::{ AuthState, CoreStorage as PlatformCoreStorage, CoreStorageKey, PermissionAuthorizationRequest, }; +use crate::runtime::signing_host::LocalActivation; use parity_scale_codec::Encode; use truapi::api::{ Account, Chain, Entropy, LocalStorage, Notifications, Permissions, Preimage, @@ -815,6 +816,8 @@ struct AudienceContactsPlatform { after_lookup: parking_lot::Mutex>>, after_pick: parking_lot::Mutex>>, after_labels: parking_lot::Mutex>>, + pick_gate: Mutex>>, + labels_gate: Mutex>>, } impl AudienceContactsPlatform { @@ -830,6 +833,8 @@ impl AudienceContactsPlatform { after_lookup: Default::default(), after_pick: Default::default(), after_labels: Default::default(), + pick_gate: Default::default(), + labels_gate: Default::default(), }) } } @@ -854,6 +859,10 @@ impl crate::platform::ContactsPlatform for AudienceContactsPlatform { selection: crate::platform::ContactSelection, ) -> Result { self.selected.lock().push(selection.selected); + let gate = self.pick_gate.lock().unwrap().take(); + if let Some(gate) = gate { + let _ = gate.await; + } if let Some(changed) = self.after_pick.lock().take() { changed(); } @@ -866,6 +875,10 @@ impl crate::platform::ContactsPlatform for AudienceContactsPlatform { placed: crate::platform::PlacedContactLabels, ) -> Result { self.labels.lock().push(placed); + let gate = self.labels_gate.lock().unwrap().take(); + if let Some(gate) = gate { + let _ = gate.await; + } if let Some(changed) = self.after_labels.lock().take() { changed(); } @@ -884,6 +897,146 @@ fn pick_many( )) } +fn local_contacts_host( + contacts: Arc, +) -> (ProductRuntimeHost, Arc) { + let mut host = contacts_host("seity.dot", stub_platform(), Some(contacts), false); + let authority = SigningHostRole::new(host.services.clone(), "dot".into(), None); + futures::executor::block_on(authority.activate_local_session(vec![0xAB; 16])).unwrap(); + host.authority = authority.clone(); + (host, authority) +} + +fn revoke_contact_authority(authority: &SigningHostRole, product: Option<&str>) { + futures::executor::block_on(async { + if let Some(product) = product { + authority.clear_product_state(product).await.unwrap(); + } else { + authority + .activate_local_session(vec![0xAB; 16]) + .await + .unwrap(); + } + }); +} + +#[test] +fn local_contact_selection_preserves_directory_and_authority_fences() { + for revoked_product in [Some("other.dot"), Some("seity.dot"), None] { + let account = [10; 32]; + let contacts = AudienceContactsPlatform::new( + vec![account], + crate::platform::HostContactsPick::Picked { + accounts: vec![account], + }, + ); + let (host, authority) = local_contacts_host(contacts.clone()); + let (_, handles) = host.contacts_picker().unwrap(); + let handle = truapi::latest::ContactHandle { + bytes: handles.mint(&account), + }; + let (release, gate) = futures::channel::oneshot::channel(); + *contacts.pick_gate.lock().unwrap() = Some(gate); + let cx = CallContext::default(); + let mut call = Box::pin(Contacts::pick_many( + &host, + &cx, + HostContactsPickManyRequest::V1(truapi::latest::HostContactsPickManyRequest { + selected: vec![], + }), + )); + assert!(call.as_mut().now_or_never().is_none()); + revoke_contact_authority(&authority, revoked_product); + release.send(()).unwrap(); + let result = futures::executor::block_on(call); + if revoked_product == Some("other.dot") { + // Product clearing also invalidates the shared contact directory. + // Retry that selection, but do not report the account disconnected. + assert!(matches!( + result, + Err(CallError::Domain(HostContactsPickManyError::V1( + truapi::latest::HostContactsPickManyError::Unknown { .. } + ))) + )); + assert_eq!( + host.services.contact_handles.get(&handle.bytes, &handles), + None + ); + let HostContactsPickManyResponse::V1(response) = pick_many(&host, vec![]).unwrap(); + assert_eq!( + response.outcome, + truapi::latest::ContactPickManyOutcome::Picked { + handles: vec![handle], + } + ); + assert_eq!( + host.services.contact_handles.get(&handle.bytes, &handles), + Some(account) + ); + } else { + assert_eq!( + result, + Err(CallError::Domain(HostContactsPickManyError::V1( + truapi::latest::HostContactsPickManyError::NotConnected, + ))) + ); + assert_eq!( + host.services.contact_handles.get(&handle.bytes, &handles), + None + ); + } + } +} + +#[test] +fn local_contact_labels_withdraw_on_product_or_directory_revocation() { + for revoked_product in [Some("other.dot"), Some("seity.dot"), None] { + let account = [10; 32]; + let contacts = AudienceContactsPlatform::new( + vec![account], + crate::platform::HostContactsPick::Dismissed, + ); + let (host, authority) = local_contacts_host(contacts.clone()); + let (_, handles) = host.contacts_picker().unwrap(); + let (release, gate) = futures::channel::oneshot::channel(); + *contacts.labels_gate.lock().unwrap() = Some(gate); + let rect = truapi::latest::AvatarRect { + x: 0, + y: 0, + width: 180, + height: 24, + }; + let cx = CallContext::default(); + let mut call = Box::pin(Contacts::place_labels( + &host, + &cx, + HostContactsPlaceLabelsRequest::V1(truapi::latest::HostContactsPlaceLabelsRequest { + surface_width: 300, + surface_height: 200, + slots: vec![truapi::latest::ContactLabelSlot { + slot: 0, + handle: truapi::latest::ContactHandle { + bytes: handles.mint(&account), + }, + rect, + clip: rect, + }], + }), + )); + assert!(call.as_mut().now_or_never().is_none()); + revoke_contact_authority(&authority, revoked_product); + release.send(()).unwrap(); + let result = futures::executor::block_on(call); + assert_eq!( + result, + Err(CallError::Domain(HostContactsPlaceLabelsError::V1( + truapi::latest::HostContactsPlaceLabelsError::NotConnected, + ))) + ); + assert!(contacts.labels.lock().last().unwrap().labels.is_empty()); + } +} + #[test] fn multi_picker_preserves_confirmed_empty_and_dismissed_outcomes() { use crate::platform::HostContactsPick; diff --git a/rust/crates/truapi/src/runtime/tests/signing.rs b/rust/crates/truapi/src/runtime/tests/signing.rs index 3c4c1d3b94..d8261c7292 100644 --- a/rust/crates/truapi/src/runtime/tests/signing.rs +++ b/rust/crates/truapi/src/runtime/tests/signing.rs @@ -1,5 +1,61 @@ use super::*; +#[test] +fn signing_cancelled_during_permission_review_cannot_persist_a_late_grant() { + let (release, gate) = futures::channel::oneshot::channel(); + let platform = Arc::new(StubPlatform { + remote_permission_gate: Mutex::new(Some(gate)), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + install_pairing_session(&host, sso_session_info()); + let cx = CallContext::with_parts( + "cancel-permission-review".to_string(), + truapi::CancellationToken::default(), + ); + let mut call = Box::pin(host.sign_raw( + &cx, + HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: v01::RawPayload::Bytes { + bytes: b"cancel before signing consent".to_vec(), + }, + }), + )); + assert!(call.as_mut().now_or_never().is_none()); + cx.cancel().cancel(); + assert!(matches!( + call.as_mut() + .now_or_never() + .expect("cancellation must stop waiting for permission"), + Err(CallError::Domain(HostSignRawError::V1( + v01::HostSignPayloadError::Unknown { .. } + ))) + )); + assert!( + release.send(()).is_err(), + "the permission review must be withdrawn" + ); + assert_eq!( + futures::executor::block_on(host.permission_authorization_status( + PermissionAuthorizationRequest::Remote(v01::RemotePermissionRequest { + permission: v01::RemotePermission::ChainSubmit, + }), + )) + .unwrap(), + PermissionAuthorizationStatus::NotDetermined, + ); + assert!(platform.sign_raw_reviews.lock().unwrap().is_empty()); + assert_eq!( + recorded_rpc_method_count(&platform.sent_rpc, "statement_submit"), + 0, + ); +} + #[test] #[allow(deprecated)] // Exercise the temporary API's paired-host wire routing. fn unwatermarked_signing_routes_product_and_legacy_accounts_without_downgrading() { diff --git a/rust/crates/truapi/src/test_support.rs b/rust/crates/truapi/src/test_support.rs index 5e35cf8729..980fecf6e4 100644 --- a/rust/crates/truapi/src/test_support.rs +++ b/rust/crates/truapi/src/test_support.rs @@ -142,6 +142,7 @@ pub struct StubPlatform { pub remote_permission_denied: bool, pub remote_permission_decisions: Mutex>, + pub remote_permission_gate: Mutex>>, /// Every `remote_permission` request, in order, so a test can assert which /// domains reached the prompt and that a stored grant suppresses a re-ask. pub remote_permission_requests: Arc>>, @@ -1332,6 +1333,10 @@ impl PlatformPermissions for StubPlatform { .lock() .expect("remote permission list mutex poisoned") .push(request); + let gate = self.remote_permission_gate.lock().unwrap().take(); + if let Some(gate) = gate { + let _ = gate.await; + } if let Some(decision) = self .remote_permission_decisions .lock() From 6e9305406ec89bd3555aaf1755ebfacf5dc9d9ce Mon Sep 17 00:00:00 2001 From: w Date: Mon, 5 Oct 2026 17:54:01 -0400 Subject: [PATCH 23/36] fix(contacts): distinguish directory interruption from revoked authority Preserve label withdrawal while returning an interruption for directory-only changes. Keep same-product and account revocation disconnected, and remove the redundant legacy-account borrow flagged by desktop Clippy. --- README.md | 3 ++- rust/crates/truapi/src/runtime.rs | 15 ++++++++----- .../crates/truapi/src/runtime/signing_host.rs | 2 +- rust/crates/truapi/src/runtime/tests.rs | 21 +++++++++++++------ 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index de1b0c9e6b..f0e1ba1e0b 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,8 @@ Contact resolution, picking and labels use the same product-scoped authority check. Independent directory-mutation fences still apply: product clearing invalidates the shared contact directory, so an interrupted selection must be retried without treating an unrelated product clear as account disconnection. -Labels invalidated while drawing are withdrawn before the call returns. +Labels invalidated while drawing are withdrawn before the call returns; +directory-only invalidation reports an interruption, not `NotConnected`. Preimage lookups that miss the core's cache read the selected network's Bulletin node through `bitswap_v1_get`. The CLI verifies the returned bytes against the diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index d12aae347d..51291915f8 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -1861,13 +1861,12 @@ impl Contacts for ProductRuntimeHost { }, ) .await; - if !session.as_ref().is_some_and(|session| { + let request_closed = !session.as_ref().is_some_and(|session| { self.authority .session_is_current(session, Some(&self.product.product_id)) }) || cx.cancel().is_cancelled() - || placement.is_closed() - || self.services.contact_handles.generation() != generation - { + || placement.is_closed(); + if request_closed || self.services.contact_handles.generation() != generation { let _ = platform .place_contact_labels( &self.product, @@ -1878,7 +1877,13 @@ impl Contacts for ProductRuntimeHost { }, ) .await; - return Err(error(Error::NotConnected)); + return Err(error(if request_closed { + Error::NotConnected + } else { + Error::Unknown { + reason: "contact labels interrupted".into(), + } + })); } if matches!(result, Ok(false) | Err(Error::Unsupported)) { return Err(CallError::Unsupported); diff --git a/rust/crates/truapi/src/runtime/signing_host.rs b/rust/crates/truapi/src/runtime/signing_host.rs index bc3d211c96..2f1ae48893 100644 --- a/rust/crates/truapi/src/runtime/signing_host.rs +++ b/rust/crates/truapi/src/runtime/signing_host.rs @@ -1376,7 +1376,7 @@ impl ProductAuthority for SigningHost { product_account, request, } => { - let keypair = self.product_keypair(&product_account)?; + let keypair = self.product_keypair(product_account)?; // Defense-in-depth: the slot-zero key must match the legacy // signer the caller asked for (also validated upstream). Never // sign with a diverging key. diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index dec7794b93..4fb9c2eace 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -1027,12 +1027,21 @@ fn local_contact_labels_withdraw_on_product_or_directory_revocation() { revoke_contact_authority(&authority, revoked_product); release.send(()).unwrap(); let result = futures::executor::block_on(call); - assert_eq!( - result, - Err(CallError::Domain(HostContactsPlaceLabelsError::V1( - truapi::latest::HostContactsPlaceLabelsError::NotConnected, - ))) - ); + if revoked_product == Some("other.dot") { + assert!(matches!( + result, + Err(CallError::Domain(HostContactsPlaceLabelsError::V1( + truapi::latest::HostContactsPlaceLabelsError::Unknown { .. } + ))) + )); + } else { + assert_eq!( + result, + Err(CallError::Domain(HostContactsPlaceLabelsError::V1( + truapi::latest::HostContactsPlaceLabelsError::NotConnected, + ))) + ); + } assert!(contacts.labels.lock().last().unwrap().labels.is_empty()); } } From 8676393fc30429ae78bb94bea9d22c95ca00ad57 Mon Sep 17 00:00:00 2001 From: w Date: Mon, 5 Oct 2026 17:54:01 -0400 Subject: [PATCH 24/36] style(core): format receiving and callback generation sources Apply the pinned nightly formatter to the seven inherited files blocking Rust CI. No logic changes. --- .../truapi-codegen/src/rust/wasm_bridge.rs | 95 ++++++++++++++----- .../truapi-codegen/src/ts/host_callbacks.rs | 46 ++++++--- rust/crates/truapi-codegen/tests/emission.rs | 3 - rust/crates/truapi/src/api/notifications.rs | 19 ++-- rust/crates/truapi/src/lib.rs | 15 ++- rust/crates/truapi/src/v01/notifications.rs | 30 ++++-- .../truapi/src/versioned/notifications.rs | 2 +- 7 files changed, 148 insertions(+), 62 deletions(-) diff --git a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs index 48d09a6fab..0ac4108689 100644 --- a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -192,7 +192,8 @@ impl<'a> BridgeCtx<'a> { } fn is_encoded_codec(&self, ty: &TypeRef) -> bool { - self.is_api_codec(ty) || self.is_local_codec(ty) + self.is_api_codec(ty) + || self.is_local_codec(ty) || matches!(ty, TypeRef::Vec(inner) if self.is_encoded_codec(inner)) } @@ -252,10 +253,14 @@ fn bridge_fields( field_name: raw_callback_field_name(trait_def, method, platform_trait_names), raw_name: raw_callback_wire_name(trait_def, method, platform_trait_names), optional: optional || method.has_default, - absent_is_none: method.has_default && matches!( - &method.return_shape.inner, - PlatformInner::Result { ok: TypeRef::Option(_), .. } - ), + absent_is_none: method.has_default + && matches!( + &method.return_shape.inner, + PlatformInner::Result { + ok: TypeRef::Option(_), + .. + } + ), namespace: namespace.clone(), }); } @@ -368,8 +373,13 @@ fn emit_result_method( && ctx.is_encoded_codec(inner) { let call = bridge_call( - "invoke_optional_bytes_return", &method.name, &args, - &[format!("{:?}", format!("{raw} must resolve to Uint8Array, null or undefined"))], + "invoke_optional_bytes_return", + &method.name, + &args, + &[format!( + "{:?}", + format!("{raw} must resolve to Uint8Array, null or undefined") + )], ); let inner_type = rust_type(inner, ctx)?; formatdoc! { @@ -644,10 +654,14 @@ fn js_arg_expr(name: &str, ty: &TypeRef, ctx: &BridgeCtx<'_>) -> Result } if let TypeRef::Option(inner) = ty { if ctx.is_encoded_codec(inner) { - return Ok(format!("{name}.as_ref().map_or(JsValue::UNDEFINED, |value| Uint8Array::from(value.encode().as_slice()).into())")); + return Ok(format!( + "{name}.as_ref().map_or(JsValue::UNDEFINED, |value| Uint8Array::from(value.encode().as_slice()).into())" + )); } if is_bytes(inner) { - return Ok(format!("{name}.as_ref().map_or(JsValue::UNDEFINED, |value| Uint8Array::from(value.as_slice()).into())")); + return Ok(format!( + "{name}.as_ref().map_or(JsValue::UNDEFINED, |value| Uint8Array::from(value.as_slice()).into())" + )); } } if let Some(primitive) = ctx.alias_primitive(ty) { @@ -892,23 +906,44 @@ mod tests { } fn authority_type() -> TypeRef { - TypeRef::Named { name: "ReceivingAuthority".into(), args: Vec::new() } + TypeRef::Named { + name: "ReceivingAuthority".into(), + args: Vec::new(), + } } #[test] fn optional_codec_result_propagates_callback_failure_before_decode() { let ok = TypeRef::Option(Box::new(authority_type())); - let error = TypeRef::Named { name: "GenericError".into(), args: Vec::new() }; + let error = TypeRef::Named { + name: "GenericError".into(), + args: Vec::new(), + }; let method = PlatformMethod { - name: "receiver_authority".into(), docs: None, - params: vec![PlatformParam { name: "product".into(), type_ref: TypeRef::Primitive("str".into()), borrowed: true }], - return_shape: PlatformReturn { is_async: true, inner: PlatformInner::Result { ok: ok.clone(), err: error.clone() } }, + name: "receiver_authority".into(), + docs: None, + params: vec![PlatformParam { + name: "product".into(), + type_ref: TypeRef::Primitive("str".into()), + borrowed: true, + }], + return_shape: PlatformReturn { + is_async: true, + inner: PlatformInner::Result { + ok: ok.clone(), + err: error.clone(), + }, + }, has_default: true, }; let output = emit_result_method(&method, &ok, &error, &context()).unwrap(); assert!(output.contains("product: &str"), "{output}"); assert!(output.contains(".await.map_err(generic)?;"), "{output}"); - assert!(output.contains("bytes.map(|bytes| decode_bytes::"), "{output}"); + assert!( + output + .contains("bytes.map(|bytes| decode_bytes::"), + "{output}" + ); assert!(output.contains(".transpose()"), "{output}"); } @@ -916,8 +951,10 @@ mod tests { fn optional_and_vector_codec_arguments_use_matching_scale_payloads() { let context = context(); let vector = TypeRef::Vec(Box::new(authority_type())); - assert_eq!(js_arg_expr("watches", &vector, &context).unwrap(), - "Uint8Array::from(watches.encode().as_slice()).into()"); + assert_eq!( + js_arg_expr("watches", &vector, &context).unwrap(), + "Uint8Array::from(watches.encode().as_slice()).into()" + ); let optional = TypeRef::Option(Box::new(authority_type())); let output = js_arg_expr("authority", &optional, &context).unwrap(); assert!(output.contains("map_or(JsValue::UNDEFINED")); @@ -927,14 +964,26 @@ mod tests { #[test] fn optional_default_authority_is_absent_not_successful_enrollment() { let method = PlatformMethod { - name: "receiver_authority".into(), docs: None, params: Vec::new(), - return_shape: PlatformReturn { is_async: true, inner: PlatformInner::Result { - ok: TypeRef::Option(Box::new(authority_type())), - err: TypeRef::Named { name: "GenericError".into(), args: Vec::new() }, - } }, + name: "receiver_authority".into(), + docs: None, + params: Vec::new(), + return_shape: PlatformReturn { + is_async: true, + inner: PlatformInner::Result { + ok: TypeRef::Option(Box::new(authority_type())), + err: TypeRef::Named { + name: "GenericError".into(), + args: Vec::new(), + }, + }, + }, has_default: true, }; - let notifications = PlatformTrait { name: "Notifications".into(), docs: None, methods: vec![method] }; + let notifications = PlatformTrait { + name: "Notifications".into(), + docs: None, + methods: vec![method], + }; let fields = bridge_fields(&[¬ifications], &BTreeSet::new(), &BTreeSet::new()); assert!(fields[0].optional); assert!(fields[0].absent_is_none); diff --git a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs index 072e3c91ba..794350bfc3 100644 --- a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -285,7 +285,10 @@ fn emit_wasm_adapter( .unwrap(); let needs_scale = traits.iter().flat_map(|t| &t.methods).any(|method| { let vector_codec = |ty: &TypeRef| { - let ty = match ty { TypeRef::Option(inner) => inner.as_ref(), other => other }; + let ty = match ty { + TypeRef::Option(inner) => inner.as_ref(), + other => other, + }; matches!(ty, TypeRef::Vec(_)) && encoded_codec_expr(ty, codec_types, local_codec_types).is_some() }; @@ -1282,7 +1285,8 @@ fn encoded_codec_expr( ) -> Option { match ty { TypeRef::Named { name, args } - if args.is_empty() && (codec_types.contains(name) || local_codec_types.contains(name)) => + if args.is_empty() + && (codec_types.contains(name) || local_codec_types.contains(name)) => { Some(name.clone()) } @@ -1316,7 +1320,9 @@ fn adapter_unary_impl( } else if let TypeRef::Option(inner) = ok && let Some(codec) = encoded_codec_expr(inner, codec_types, local_codec_types) { - format!("{{ const value = await {call}; return value == null ? undefined : {codec}.enc(value); }}") + format!( + "{{ const value = await {call}; return value == null ? undefined : {codec}.enc(value); }}" + ) } else { format!("await {call}") }; @@ -1459,7 +1465,9 @@ fn local_codec_expr_for_type(type_def: &TypeDef) -> Result { )); } let indexed = variants.iter().enumerate().any(|(position, variant)| { - variant.codec_index.is_some_and(|index| index as usize != position) + variant + .codec_index + .is_some_and(|index| index as usize != position) }); let entries = variants .iter() @@ -1475,7 +1483,11 @@ fn local_codec_expr_for_type(type_def: &TypeDef) -> Result { }) .collect::>>()? .join(", "); - let constructor = if indexed { "indexedTaggedUnion" } else { "TaggedUnion" }; + let constructor = if indexed { + "indexedTaggedUnion" + } else { + "TaggedUnion" + }; Ok(format!("S.{constructor}({{{entries}}})")) } } @@ -2065,10 +2077,14 @@ mod tests { #[test] fn wasm_adapter_encodes_optional_and_vector_codec_results() { for (shape, expected) in [ - (TypeRef::Vec(Box::new(named("HostFeatureSupportedResponse"))), - "featureSupportedResultCodec.enc(await callbacks.features.featureSupported("), - (TypeRef::Option(Box::new(named("HostFeatureSupportedResponse"))), - "value == null ? undefined : HostFeatureSupportedResponse.enc(value)"), + ( + TypeRef::Vec(Box::new(named("HostFeatureSupportedResponse"))), + "featureSupportedResultCodec.enc(await callbacks.features.featureSupported(", + ), + ( + TypeRef::Option(Box::new(named("HostFeatureSupportedResponse"))), + "value == null ? undefined : HostFeatureSupportedResponse.enc(value)", + ), ] { let definition = platform_with_method(method_with_return(shape)); let output = emit_wasm_adapter(&definition, &codec_types(), &BTreeSet::new()).unwrap(); @@ -2079,10 +2095,14 @@ mod tests { #[test] fn wasm_adapter_decodes_optional_and_vector_codec_parameters() { for (shape, expected) in [ - (TypeRef::Vec(Box::new(named("HostFeatureSupportedRequest"))), - "S.Vector(HostFeatureSupportedRequest).dec(request)"), - (TypeRef::Option(Box::new(named("HostFeatureSupportedRequest"))), - "request == null ? undefined : HostFeatureSupportedRequest.dec(request)"), + ( + TypeRef::Vec(Box::new(named("HostFeatureSupportedRequest"))), + "S.Vector(HostFeatureSupportedRequest).dec(request)", + ), + ( + TypeRef::Option(Box::new(named("HostFeatureSupportedRequest"))), + "request == null ? undefined : HostFeatureSupportedRequest.dec(request)", + ), ] { let definition = platform_with_method(method_with_param(shape)); let output = emit_wasm_adapter(&definition, &codec_types(), &BTreeSet::new()).unwrap(); diff --git a/rust/crates/truapi-codegen/tests/emission.rs b/rust/crates/truapi-codegen/tests/emission.rs index 511fde49f6..bf2a6c7068 100644 --- a/rust/crates/truapi-codegen/tests/emission.rs +++ b/rust/crates/truapi-codegen/tests/emission.rs @@ -24,7 +24,6 @@ fn nightly_toolchain() -> String { }) } - /// Path to the rustdoc JSON of `truapi`'s protocol definitions alone, the /// input codegen reads the API from, building it on first use. fn produce_rustdoc_json(workspace_root: &Path) -> PathBuf { @@ -123,7 +122,6 @@ fn workspace_tempdir(workspace: &Path) -> tempfile::TempDir { .expect("workspace tempdir") } - /// Idempotence guard at the integration level: running the binary twice /// against the same input must produce identical output. This catches /// non-determinism (HashMap iteration order, timestamps, etc.) that the @@ -206,4 +204,3 @@ fn read_tree(root: &Path) -> BTreeMap { } files } - diff --git a/rust/crates/truapi/src/api/notifications.rs b/rust/crates/truapi/src/api/notifications.rs index df77831d89..df2c978547 100644 --- a/rust/crates/truapi/src/api/notifications.rs +++ b/rust/crates/truapi/src/api/notifications.rs @@ -1,16 +1,16 @@ //! Unified [`Notifications`] trait. use crate::versioned::notifications::{ + HostNotificationAcknowledgeReceiverEventRequest, + HostNotificationAcknowledgeReceiverEventResponse, HostNotificationDisableReceiverRequest, + HostNotificationDisableReceiverResponse, HostNotificationReceiverEventsRequest, + HostNotificationReceiverEventsResponse, HostNotificationReceiverStatusRequest, + HostNotificationReceiverStatusResponse, HostNotificationReceivingError, + HostNotificationRecordReceiptRequest, HostNotificationRecordReceiptResponse, + HostNotificationReplaceReceiverRequest, HostNotificationReplaceReceiverResponse, HostPushNotificationCancelError, HostPushNotificationCancelRequest, HostPushNotificationCancelResponse, HostPushNotificationError, HostPushNotificationRequest, HostPushNotificationResponse, - HostNotificationReceiverStatusRequest, HostNotificationReceiverStatusResponse, - HostNotificationReplaceReceiverRequest, HostNotificationReplaceReceiverResponse, - HostNotificationDisableReceiverRequest, HostNotificationDisableReceiverResponse, - HostNotificationRecordReceiptRequest, HostNotificationRecordReceiptResponse, - HostNotificationReceiverEventsRequest, HostNotificationReceiverEventsResponse, - HostNotificationAcknowledgeReceiverEventRequest, HostNotificationAcknowledgeReceiverEventResponse, - HostNotificationReceivingError, }; use crate::{CallContext, CallError}; use crate::{wire, wire_trait}; @@ -169,5 +169,8 @@ pub trait Notifications: Send + Sync { &self, cx: &CallContext, request: HostNotificationAcknowledgeReceiverEventRequest, - ) -> Result>; + ) -> Result< + HostNotificationAcknowledgeReceiverEventResponse, + CallError, + >; } diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 45d95b3d37..5461521169 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -105,12 +105,17 @@ pub mod latest { HostJamPeerTransportOpenResponse, HostJamPeerTransportRecvError, HostJamPeerTransportRecvRequest, HostJamPeerTransportRecvResponse, HostJamPeerTransportResetError, HostJamPeerTransportResetRequest, - HostJamPeerTransportSendError, HostJamPeerTransportSendRequest, HostPlatform, + HostJamPeerTransportSendError, HostJamPeerTransportSendRequest, + HostNotificationAcknowledgeReceiverEventRequest, HostNotificationDisableReceiverRequest, + HostNotificationReceiptResult, HostNotificationReceiverEventsRequest, + HostNotificationReceiverStatus, HostNotificationReceivingError, + HostNotificationRecordReceiptRequest, HostNotificationReplaceReceiverRequest, HostPlatform, HostSignPayloadData, HostWorkerOperationError, ImageFit, ImageProps, ImageSource, JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION, JAM_PEER_TRANSPORT_MAX_CONNECTIONS, JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES, JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION, JamPeerTransportEvent, Modifier, OperationStartedResult, PocketCard, ProductAccountId, - ProductProofContext, RawPayload, RegisteredRingVrfKey, RemotePermission, + ProductProofContext, RawPayload, ReceivingEvent, ReceivingEventKind, ReceivingReceiptKind, + ReceivingWatch, RegisteredRingVrfKey, RemotePermission, RemoteStatementStoreCreateProofError, RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, RemoteStatementStoreSubscribeItem, RemoteStatementStoreSubscribeRequest, RenderContext, RendererNode, RingLocation, @@ -118,12 +123,6 @@ pub mod latest { Shape, SignedStatement, Size, Statement, StatementProof, StorageQueryItem, StorageQueryType, StorageResultItem, TextFieldProps, TextProps, ThemeName, ThemeVariant, TxPayloadExtension, TypographyStyle, VerticalAlignment, VrfSignature, - HostNotificationReceiverStatus, HostNotificationReceivingError, - HostNotificationReceiptResult, - HostNotificationReplaceReceiverRequest, HostNotificationDisableReceiverRequest, - HostNotificationRecordReceiptRequest, HostNotificationReceiverEventsRequest, - HostNotificationAcknowledgeReceiverEventRequest, ReceivingWatch, ReceivingEvent, - ReceivingEventKind, ReceivingReceiptKind, }; pub use crate::v02::{ HostNativeChatAcknowledgment, HostNativeChatAttachment, HostNativeChatAttachmentKind, diff --git a/rust/crates/truapi/src/v01/notifications.rs b/rust/crates/truapi/src/v01/notifications.rs index 9245711c2e..d5e9d3db07 100644 --- a/rust/crates/truapi/src/v01/notifications.rs +++ b/rust/crates/truapi/src/v01/notifications.rs @@ -53,7 +53,10 @@ pub struct HostPushNotificationCancelRequest { /// An authenticated source filter enrolled under host-owned receiving consent. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] #[cfg_attr(feature = "runtime", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct ReceivingWatch { /// Product-local opaque watch identifier. pub id: String, @@ -76,7 +79,10 @@ pub struct ReceivingWatch { /// Receiving support, consent and durable synchronization state. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] #[cfg_attr(feature = "runtime", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct HostNotificationReceiverStatus { /// Whether this host supplies trusted receiving authority. pub supported: bool, @@ -97,7 +103,10 @@ pub struct HostNotificationReceiverStatus { /// Confirmed application handling, independent of transport acknowledgement. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] #[cfg_attr(feature = "runtime", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Enum))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum ReceivingReceiptKind { /// The application handled the event in the foreground; not proof of OS display. Foreground, @@ -110,7 +119,10 @@ pub enum ReceivingReceiptKind { /// Actual display outcome after recording a receipt, distinct from enrollment ACKs. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] #[cfg_attr(feature = "runtime", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct HostNotificationReceiptResult { /// An OS display was positively confirmed by the host or product. pub displayed: bool, @@ -122,7 +134,10 @@ pub struct HostNotificationReceiptResult { /// Why an authenticated receiving event was queued. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] #[cfg_attr(feature = "runtime", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Enum))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum ReceivingEventKind { /// An authenticated event arrived without focusing the product. Delivery, @@ -133,7 +148,10 @@ pub enum ReceivingEventKind { /// A bounded durable event containing opaque identifiers, never plaintext. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] #[cfg_attr(feature = "runtime", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(all(feature = "runtime", not(target_arch = "wasm32")), derive(uniffi::Record))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct ReceivingEvent { /// Durable sequence used for polling and acknowledgement. pub sequence: u64, diff --git a/rust/crates/truapi/src/versioned/notifications.rs b/rust/crates/truapi/src/versioned/notifications.rs index 7921af576c..2a39dd61af 100644 --- a/rust/crates/truapi/src/versioned/notifications.rs +++ b/rust/crates/truapi/src/versioned/notifications.rs @@ -1,7 +1,7 @@ //! Versioned wrappers for [`Notifications`](crate::api::Notifications) methods. -use alloc::vec::Vec; use crate::v01; +use alloc::vec::Vec; truapi_macros::versioned_type! { pub enum HostPushNotificationRequest { V1 => v01::HostPushNotificationRequest } From 4991d81b8a7cf395b48707af9501ffacc764966c Mon Sep 17 00:00:00 2001 From: w Date: Mon, 5 Oct 2026 19:36:22 -0400 Subject: [PATCH 25/36] chore(core): preserve receiving API under strict lint Use the constant missing-authority error directly and document argument-count exceptions only on the existing core/native receiving ingress methods. Keep API signatures and source/frame validation unchanged. --- rust/crates/truapi/src/native/runtime.rs | 4 ++++ rust/crates/truapi/src/runtime/capabilities/platform.rs | 2 +- rust/crates/truapi/src/runtime/receiving.rs | 4 ++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/rust/crates/truapi/src/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs index 93a83215c2..3287f4e1b7 100644 --- a/rust/crates/truapi/src/native/runtime.rs +++ b/rust/crates/truapi/src/native/runtime.rs @@ -277,6 +277,10 @@ impl NativeTrUApiHostRuntime { } /// Verify a complete frame against its independently observed chain and topics. + #[allow( + clippy::too_many_arguments, + reason = "Preserve the native receiving API shared with generated host bindings" + )] pub async fn receiving_ingest( &self, product_id: String, revision: u64, watch_id: String, actual_genesis: String, actual_channel: String, actual_topics: Vec, frame: Vec, diff --git a/rust/crates/truapi/src/runtime/capabilities/platform.rs b/rust/crates/truapi/src/runtime/capabilities/platform.rs index 1764299c51..2f53b4b6b8 100644 --- a/rust/crates/truapi/src/runtime/capabilities/platform.rs +++ b/rust/crates/truapi/src/runtime/capabilities/platform.rs @@ -476,7 +476,7 @@ impl ProductRuntimeHost { ) -> Result> { let authority = self.platform.receiver_authority(&self.product.product_id).await .map_err(|error| CallError::HostFailure { reason: error.reason })? - .ok_or_else(|| CallError::Domain(HostNotificationReceivingError::V1( + .ok_or(CallError::Domain(HostNotificationReceivingError::V1( crate::latest::HostNotificationReceivingError::Unsupported, )))?; if authority.product_id != self.product.product_id { diff --git a/rust/crates/truapi/src/runtime/receiving.rs b/rust/crates/truapi/src/runtime/receiving.rs index 096572b2b4..d757cf8855 100644 --- a/rust/crates/truapi/src/runtime/receiving.rs +++ b/rust/crates/truapi/src/runtime/receiving.rs @@ -529,6 +529,10 @@ impl ReceivingService { /// Validate actual source and the complete authenticated frame before minting /// a local handle. Provider payload IDs alone must never call this path. + #[allow( + clippy::too_many_arguments, + reason = "Keep observed source fields explicit and preserve the host receiving API" + )] pub async fn ingest(&self, product: &str, revision: u64, watch_id: String, actual_genesis: String, actual_channel: String, actual_topics: Vec, frame: Vec) -> Result, Error> { From 894881a82ae6e2d893a2943c150008a76806cce2 Mon Sep 17 00:00:00 2001 From: w Date: Mon, 5 Oct 2026 19:58:16 -0400 Subject: [PATCH 26/36] chore: format inherited notification wire scope coverage --- rust/crates/truapi/tests/wire_result_shape.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rust/crates/truapi/tests/wire_result_shape.rs b/rust/crates/truapi/tests/wire_result_shape.rs index dd62be4322..cb205ef277 100644 --- a/rust/crates/truapi/tests/wire_result_shape.rs +++ b/rust/crates/truapi/tests/wire_result_shape.rs @@ -101,7 +101,11 @@ fn notification_activation_requests_cannot_select_foreign_scope() { }; assert_eq!(unsupported.payload.message_type, MESSAGE_TYPE_RESPONSE); assert_eq!(unsupported.payload.value, expected); - for foreign_scope in ["another-product.paseo", "another-account", "another-environment"] { + for foreign_scope in [ + "another-product.paseo", + "another-account", + "another-environment", + ] { let mut value = request.clone(); value.extend(foreign_scope.encode()); let response = dispatch( From 8cc1d624359175e45e253c83c20622ae159c1b10 Mon Sep 17 00:00:00 2001 From: w Date: Mon, 5 Oct 2026 22:53:23 -0400 Subject: [PATCH 27/36] fix(receiving): preserve clicks when unchanged watches are republished --- js/packages/truapi-host/README.md | 6 +++ rust/crates/truapi/src/runtime/receiving.rs | 8 ++++ .../truapi/src/runtime/receiving/tests.rs | 46 +++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index e01bcb81ce..2390b552ab 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -209,6 +209,12 @@ resolves and cancels reservations only on explicit display failure. Retained v2 responses contain a carrier and actual source metadata, so this adapter uses `receivingIngest`, not `receivingIngestStatement`. +Republishing an identical watch snapshot under the same live authority preserves +its registration revision, synchronization state, and queued events. This lets +cold-start products restore their watches without invalidating the click that +opened them. Stale compare-and-swap tokens still fail; a changed policy, including +its activation route or expiry, advances the revision and fences old clicks. + Relay watches retain each original core-consent expiry, at most 30 days, without a separate 24-hour lease or dependence on periodic browser execution. Transport rotation and retries never extend that expiry; renewal beyond it requires fresh diff --git a/rust/crates/truapi/src/runtime/receiving.rs b/rust/crates/truapi/src/runtime/receiving.rs index 096572b2b4..bff6620311 100644 --- a/rust/crates/truapi/src/runtime/receiving.rs +++ b/rust/crates/truapi/src/runtime/receiving.rs @@ -241,6 +241,14 @@ impl ReceivingService { check_revision(find(&ledger, &authority), expected_revision)?; let timestamp = now(); validate_watches(&watches, &authority, timestamp)?; + // Startup may republish the same policy before a pending click is delivered. + // Only a real policy change should fence that click or restart relay sync. + if let Some(record) = find(&ledger, &authority) { + if current(record, &authority) && record.consent && record.enabled == !watches.is_empty() + && record.watches.iter().map(|watch| &watch.policy).eq(watches.iter()) { + return Ok(status(&fresh, Some(record))); + } + } let position = ledger.records.iter().position(|r| same_scope(&r.authority, &authority)); if position.is_none() && ledger.records.len() >= MAX_REGISTRATIONS { return Err(Error::Capacity); } // A product's previous account/artifact cannot keep receiving accidentally. diff --git a/rust/crates/truapi/src/runtime/receiving/tests.rs b/rust/crates/truapi/src/runtime/receiving/tests.rs index 6d8e012371..d179d51d5b 100644 --- a/rust/crates/truapi/src/runtime/receiving/tests.rs +++ b/rust/crates/truapi/src/runtime/receiving/tests.rs @@ -119,6 +119,52 @@ fn display_reservation_and_activation_are_distinct_and_replay_safe() { }); } +#[test] +fn unchanged_watch_republication_preserves_cold_activation() { + block_on(async { + let (platform, service) = setup(); + let watch = watch(); + let revision = service.replace(PRODUCT, 0, vec![watch.clone()]).await.unwrap().revision; + let event = deliver(&service, revision, 3).await.unwrap().remove(0); + service.prepare_display(PRODUCT, revision, event.event_id.clone()).await.unwrap().unwrap(); + service.confirm_display(PRODUCT, revision, event.event_id.clone()).await.unwrap(); + assert!(service.synchronized(PRODUCT, revision).await.unwrap()); + drop(service); + let restored = ReceivingService::new(platform, test_spawner()); + let status = restored.replace(PRODUCT, revision, vec![watch.clone()]).await.unwrap(); + assert_eq!(status.revision, revision); + assert!(!status.sync_pending); + assert_eq!(restored.events(PRODUCT, 0).await.unwrap(), vec![event.clone()]); + let preview = restored.validate_activation(PRODUCT, revision, event.event_id.clone()).await.unwrap().unwrap(); + assert_eq!(preview.route, watch.route); + let activation = restored.activate(PRODUCT, revision, event.event_id.clone()).await.unwrap().unwrap(); + restored.replace(PRODUCT, revision, vec![watch]).await.unwrap(); + assert!(restored.events(PRODUCT, 0).await.unwrap().contains(&activation)); + assert!(restored.prepare_display(PRODUCT, revision, event.event_id.clone()).await.unwrap().is_none()); + assert!(restored.activate(PRODUCT, revision, event.event_id).await.unwrap().is_none()); + assert!(deliver(&restored, revision, 3).await.unwrap().is_empty()); + }); +} + +#[test] +fn changed_route_still_fences_pending_click_and_stale_replacement() { + block_on(async { + let (_, service) = setup(); + let mut watch = watch(); + let revision = service.replace(PRODUCT, 0, vec![watch.clone()]).await.unwrap().revision; + let event = deliver(&service, revision, 3).await.unwrap().remove(0); + service.prepare_display(PRODUCT, revision, event.event_id.clone()).await.unwrap().unwrap(); + service.confirm_display(PRODUCT, revision, event.event_id.clone()).await.unwrap(); + watch.route = "/another-conversation".into(); + let changed = service.replace(PRODUCT, revision, vec![watch.clone()]).await.unwrap(); + assert_ne!(changed.revision, revision); + assert!(matches!(service.validate_activation(PRODUCT, revision, event.event_id.clone()).await, Err(Error::Conflict))); + assert!(matches!(service.activate(PRODUCT, revision, event.event_id).await, Err(Error::Conflict))); + assert!(matches!(service.replace(PRODUCT, revision, vec![watch]).await, Err(Error::Conflict))); + assert!(service.events(PRODUCT, 0).await.unwrap().is_empty()); + }); +} + #[test] fn message_receipts_do_not_acknowledge_a_queued_user_activation() { block_on(async { From 3bb6132b04414828233b90f26c7b28bec6e505fc Mon Sep 17 00:00:00 2001 From: w Date: Thu, 8 Oct 2026 19:47:50 -0400 Subject: [PATCH 28/36] fix(jam-peers): bound transport resources and fence cancellation --- .changeset/pvm-jam-peer-transport.md | 19 + docs/rfcs/0002-permission-model.md | 8 +- .../common/src/main/res/values/strings.xml | 2 +- .../compose/PermissionMapping.kt | 4 +- .../components/ProductPermissionItem.kt | 7 +- .../RealProductPermissionGuardTest.kt | 16 + .../Permissions/Model/ProductPermission.swift | 2 +- .../Model/RemotePermissionRequest.swift | 2 +- .../Localization/Products.xcstrings | 4 +- .../ProductPermissionPromptViewFactory.swift | 4 +- .../AppPermissionsViewModelFactory.swift | 2 +- .../ProductPermissionRepositoryTests.swift | 32 + .../truapi/src/jam-peer-transport.test.ts | 295 ++++++++- js/packages/truapi/src/jam-peer-transport.ts | 378 ++++++++--- rust/crates/truapi/RUNTIME.md | 35 +- .../truapi/src/api/jam_peer_transport.rs | 7 +- rust/crates/truapi/src/jam_peer_transport.rs | 10 +- .../truapi/src/jam_peer_transport/quic.rs | 612 +++++++++++++++--- .../truapi/src/jam_peer_transport/session.rs | 102 ++- .../src/jam_peer_transport/session/tests.rs | 247 ++++++- .../capabilities/jam_peer_transport.rs | 4 +- .../truapi/src/v01/jam_peer_transport.rs | 8 +- rust/crates/truapi/src/v01/permissions.rs | 14 +- 23 files changed, 1548 insertions(+), 266 deletions(-) diff --git a/.changeset/pvm-jam-peer-transport.md b/.changeset/pvm-jam-peer-transport.md index 2f0935d01c..0e172b3b2f 100644 --- a/.changeset/pvm-jam-peer-transport.md +++ b/.changeset/pvm-jam-peer-transport.md @@ -17,3 +17,22 @@ The browser adapter pins two certificates per validity period: PolkaJAM's stock serial is derived from the peer's P-256 key and period (first 8 bytes of SHA-256(compressed key ‖ period as big-endian u64), top bit cleared, 1 if zero). Firefox's NSS rejects a second certificate with the same issuer and serial, so with stock nodes it reaches one validator; nodes that use the derived serial are all reachable, and stock nodes keep working. + +Receive queues reserve length-prefixed message bytes against the per-connection budget before allocating payloads, +including empty messages, and apply backpressure until the guest drains or resets a stream. Pending opens reserve stream +slots before transport setup; cancelled or closed operations cannot publish late streams. Browser cancellation also +settles blocked stream opens and writes, not only dials. Cancelled browser writes retain their connection quota until +the underlying sink releases the buffered frame. +Native stream senders carry reset-on-drop guards from asynchronous opening onward, including results abandoned before +the caller observes them. +Native and browser dials reserve from the eight-connection budget before awaiting permission, and retain at most eight distinct +genesis decisions per execution, including pending and refused decisions. A new ninth network returns `Limit`, without +evicting or re-prompting old decisions. Cancellation frees its operation slot and removes its decision subscriber. + +The full genesis scopes permission decisions, not cryptographic validator membership. Native QUIC negotiates the +JAMNP-S ALPN containing a genesis prefix; WebTransport uses HTTP/3, and the current PolkaJAM CONNECT endpoint does not +negotiate a genesis. Both transports pin caller-supplied peer keys. Guests must verify chain data themselves; access +permits sending as well as receiving peer messages and is not read-only. + +Android and iOS consent now describe bidirectional messaging and display the complete genesis in the permission +prompt, including batched requests, and in permission details. Short summary titles do not replace the full identity. diff --git a/docs/rfcs/0002-permission-model.md b/docs/rfcs/0002-permission-model.md index 389c25ecab..0482d1079a 100644 --- a/docs/rfcs/0002-permission-model.md +++ b/docs/rfcs/0002-permission-model.md @@ -137,10 +137,10 @@ enum RemotePermission { // Submit statements to the statement store via // remote_statement_store_submit. StatementSubmit, - // Read-only JAMNP-S QUIC/WebTransport peer access to the validators of - // one JAM chain via jam_peer_transport_dial. The product names the - // endpoints; every byte received is untrusted. Decided per product and - // genesis. + // Bidirectional JAMNP-S QUIC/WebTransport peer messaging via + // jam_peer_transport_dial. The product names endpoints and peer keys; + // every byte received is untrusted. Decided per product and full genesis, + // without granting account access or signing authority. JamPeers { genesis: [u8; 32] } } ``` diff --git a/hosts/android/common/src/main/res/values/strings.xml b/hosts/android/common/src/main/res/values/strings.xml index 37e6e1cb12..22d5e4dad6 100644 --- a/hosts/android/common/src/main/res/values/strings.xml +++ b/hosts/android/common/src/main/res/values/strings.xml @@ -903,7 +903,7 @@ I’m attaching PDF with more information and details regarding the Tattoo Stenc %1$s would like to submit preimages Preimages will be uploaded to the bulletin chain. %1$s %1$s would like to connect to JAM network %2$s - Read-only peer access to this network\'s validators, with no accounts or signing. %1$s + Send and receive messages with peers selected by this app, with no access to your accounts or signing keys. %1$s %1$s Would Like Access to: Access %1$s Use WebRTC diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/permissionPrompt/compose/PermissionMapping.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/permissionPrompt/compose/PermissionMapping.kt index 77d35d6fa0..6987479881 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/permissionPrompt/compose/PermissionMapping.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/permissionPrompt/compose/PermissionMapping.kt @@ -95,7 +95,7 @@ internal fun ProductPermission.subtitle(): String { ProductPermission.RemotePermission.ChainSubmitAccess -> stringResource(RCommon.string.product_permission_chain_submit_subtitle, manageLater) ProductPermission.RemotePermission.StatementSubmitAccess -> stringResource(RCommon.string.product_permission_statement_submit_subtitle, manageLater) ProductPermission.RemotePermission.PreimageSubmitAccess -> stringResource(RCommon.string.product_permission_preimage_submit_subtitle, manageLater) - is ProductPermission.RemotePermission.JamPeersAccess -> stringResource(RCommon.string.product_permission_jam_peers_subtitle, manageLater) + is ProductPermission.RemotePermission.JamPeersAccess -> stringResource(RCommon.string.product_permission_jam_peers_subtitle, manageLater) + "\n\n" + genesis } } @@ -123,6 +123,6 @@ internal fun ProductPermission.RemotePermission.shortLabel(): String { ProductPermission.RemotePermission.ChainSubmitAccess -> stringResource(RCommon.string.product_permission_chain_submit_label) ProductPermission.RemotePermission.StatementSubmitAccess -> stringResource(RCommon.string.product_permission_statement_submit_label) ProductPermission.RemotePermission.PreimageSubmitAccess -> stringResource(RCommon.string.product_permission_preimage_submit_label) - is ProductPermission.RemotePermission.JamPeersAccess -> stringResource(RCommon.string.product_permission_jam_peers_label, shortGenesis) + is ProductPermission.RemotePermission.JamPeersAccess -> stringResource(RCommon.string.product_permission_jam_peers_label, genesis) } } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/productPermissions/compose/components/ProductPermissionItem.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/productPermissions/compose/components/ProductPermissionItem.kt index 3b97dd407f..f0f31434ad 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/productPermissions/compose/components/ProductPermissionItem.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/productPermissions/compose/components/ProductPermissionItem.kt @@ -70,7 +70,12 @@ private fun ProductPermission.displayName(): String { @Composable private fun ProductPermission.displayDescription(): String { - return stringResource(descriptionRes()) + val description = stringResource(descriptionRes()) + return if (this is ProductPermission.RemotePermission.JamPeersAccess) { + "$description\n$genesis" + } else { + description + } } @StringRes diff --git a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/RealProductPermissionGuardTest.kt b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/RealProductPermissionGuardTest.kt index b470045f09..839e256617 100644 --- a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/RealProductPermissionGuardTest.kt +++ b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/RealProductPermissionGuardTest.kt @@ -96,6 +96,22 @@ class RealProductPermissionGuardTest { verifyGrantedPermanently() } + @Test + fun `JAM networks sharing a display prefix are prompted and granted separately`() = runBlocking { + withNotGranted() + withBatchedDecision(PermissionDecision.AllowAlways) + val first = RemotePermission.JamPeersAccess("0x10c123f0" + "ab".repeat(28)) + val second = RemotePermission.JamPeersAccess("0x10c123f0" + "cd".repeat(28)) + + assertTrue(guard.requestPermissionsBatched(productId, listOf(first, second))) + + verify(requester).promptBatched(productId, listOf(first, second)) + verify(repository).grant(productId, first) + verify(repository).grant(productId, second) + assertEquals(first, ProductPermission.fromLocal(first.typeName, first.key)) + assertEquals(second, ProductPermission.fromLocal(second.typeName, second.key)) + } + @Test fun `consumePermission consumes a grant issued while waiting for the lock without prompting`() = runBlocking { withOneTimeGrantIssuedWhileWaiting() diff --git a/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift b/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift index c5e94f1887..6623765f78 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift @@ -23,7 +23,7 @@ public enum ProductPermission: Equatable, Sendable { case chainSubmitAccess case preimageSubmitAccess case statementSubmitAccess - /// Read-only peer access to the JAM network whose genesis header hash is + /// Peer messaging access to the JAM network whose genesis header hash is /// `genesis` (lowercase `0x`-prefixed hex). case jamPeersAccess(genesis: String) case userIdentityAccess diff --git a/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/RemotePermissionRequest.swift b/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/RemotePermissionRequest.swift index 7603037bef..9aa5dd3d89 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/RemotePermissionRequest.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/RemotePermissionRequest.swift @@ -7,7 +7,7 @@ public enum RemotePermissionRequest: Equatable, Sendable { case chainSubmit case preimageSubmit case statementSubmit - /// Read-only peer access to the JAM network whose genesis header hash is + /// Peer messaging access to the JAM network whose genesis header hash is /// `genesis` (lowercase `0x`-prefixed hex). case jamPeers(genesis: String) diff --git a/hosts/ios/polkadot-app/Localization/Products.xcstrings b/hosts/ios/polkadot-app/Localization/Products.xcstrings index 941669a9cf..c20b3eb7f8 100644 --- a/hosts/ios/polkadot-app/Localization/Products.xcstrings +++ b/hosts/ios/polkadot-app/Localization/Products.xcstrings @@ -436,7 +436,7 @@ "en": { "stringUnit": { "state": "translated", - "value": "Read-only peer access to this network's validators, with no accounts or signing." + "value": "Send and receive messages with peers selected by this app, with no access to your accounts or signing keys." } } } @@ -711,7 +711,7 @@ "en": { "stringUnit": { "state": "translated", - "value": "Connect to JAM network %1$(shortGenesis)@" + "value": "Connect to JAM network %1$(genesis)@" } } } diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift b/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift index ddf0e19581..9c0d603829 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift @@ -148,7 +148,7 @@ private extension ProductPermissionPromptViewFactory { shortGenesis: ProductPermission.shortGenesis(genesis) ) ), - body: String(localized: .Products.permissionBodyJamPeers), + body: String(localized: .Products.permissionBodyJamPeers) + "\n\n" + genesis, icon: makeIcon(systemName: "point.3.connected.trianglepath.dotted") ) case .userIdentityAccess: @@ -192,7 +192,7 @@ private extension ProductPermissionPromptViewFactory { case let .jamPeersAccess(genesis): "- " + String( localized: .Products.permissionLabelJamPeers( - shortGenesis: ProductPermission.shortGenesis(genesis) + genesis: genesis ) ) case let .deviceCapability(capability): diff --git a/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift b/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift index 99e025ffc1..011b98f587 100644 --- a/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift @@ -82,7 +82,7 @@ private extension AppPermissionsViewModelFactory { String(localized: .Products.appPermissionJamPeersTitle), String( localized: .Products.permissionLabelJamPeers( - shortGenesis: ProductPermission.shortGenesis(genesis) + genesis: genesis ) ) ) diff --git a/hosts/ios/polkadot-appTests/Products/ProductPermissionRepositoryTests.swift b/hosts/ios/polkadot-appTests/Products/ProductPermissionRepositoryTests.swift index 5547aad2cf..625ffa66f7 100644 --- a/hosts/ios/polkadot-appTests/Products/ProductPermissionRepositoryTests.swift +++ b/hosts/ios/polkadot-appTests/Products/ProductPermissionRepositoryTests.swift @@ -61,6 +61,38 @@ struct ProductPermissionRepositoryTests { #expect(state == .notDetermined) } + @Test("JAM grants and revocation distinguish full genesis and product") + func jamGrantsAreScopedToFullGenesisAndProduct() async throws { + let sut = makeSUT() + let first = ProductPermission.jamPeersAccess( + genesis: "0x10c123f0" + String(repeating: "ab", count: 28) + ) + let second = ProductPermission.jamPeersAccess( + genesis: "0x10c123f0" + String(repeating: "cd", count: 28) + ) + + try await sut.grant(productId: "product-a", permission: first) + #expect(try await sut.getPermissionState( + productId: "product-a", permission: second + ) == .notDetermined) + #expect(try await sut.getPermissionState( + productId: "product-b", permission: first + ) == .notDetermined) + + try await sut.grant(productId: "product-a", permission: second) + let restored = try await sut.getAllByProduct(productId: "product-a") + #expect(restored.contains { $0.permission == first }) + #expect(restored.contains { $0.permission == second }) + + try await sut.revoke(productId: "product-a", permission: first) + #expect(try await sut.getPermissionState( + productId: "product-a", permission: first + ) == .notDetermined) + #expect(try await sut.getPermissionState( + productId: "product-a", permission: second + ) == .allowedAlways) + } + // MARK: - deny @Test("deny persists and getPermissionState returns denied") diff --git a/js/packages/truapi/src/jam-peer-transport.test.ts b/js/packages/truapi/src/jam-peer-transport.test.ts index 88d4c0aaa0..34403b41d8 100644 --- a/js/packages/truapi/src/jam-peer-transport.test.ts +++ b/js/packages/truapi/src/jam-peer-transport.test.ts @@ -16,6 +16,7 @@ import { createJamPeerTransportSession, frameTraitId, peerUrl, + JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION, JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES, type JamPeerTransportSession, type WebTransportBidirectionalStreamLike, @@ -45,16 +46,24 @@ interface FakeTransport extends WebTransportLike { peerClose(): void; } -function fakeStream(): FakeStream { +function fakeStream(write?: (chunk: Uint8Array) => Promise): FakeStream { const sent: Uint8Array[] = []; - let peerController!: ReadableStreamDefaultController; - const readable = new ReadableStream({ start: (c) => (peerController = c) }); - const writable = new WritableStream({ write: (chunk) => void sent.push(chunk) }); + let peerController!: ReadableByteStreamController; + const readable = new ReadableStream({ type: "bytes", start: (c) => (peerController = c) }); + const writable = new WritableStream({ + write: (chunk) => { + sent.push(chunk); + return write?.(chunk); + }, + }); return { local: { readable, writable }, sent, peerWrite: (bytes) => peerController.enqueue(bytes), - peerFin: () => peerController.close(), + peerFin: () => { + peerController.close(); + peerController.byobRequest?.respond(0); + }, }; } @@ -178,6 +187,86 @@ describe("peerUrl", () => { }); describe("authorization", () => { + const LIMIT = { success: false, value: { tag: "Domain", value: { tag: "V1", value: "Limit" } } }; + + test("permission waits consume connection slots and cancellation releases them before a retry", async () => { + const pending = Promise.withResolvers(); + const asked: string[] = []; + const { session, transports } = await negotiated({ + authorize: (genesis) => { + asked.push(genesis); + return genesis === GENESIS ? pending.promise : Promise.resolve(false); + }, + }); + const dials = Array.from({ length: 8 }, (_, i) => + session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, dialRequest(), `permission-${i}`))); + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest({ genesis: OTHER_GENESIS }), dialCodec)).toEqual(LIMIT); + expect(asked).toEqual([GENESIS]); + expect(transports).toHaveLength(0); + await session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, new Uint8Array(), "permission-0", MESSAGE_TYPE_CANCEL)); + expect(decodeReply(await dials[0]!, dialCodec)).toEqual({ success: false, value: { tag: "Cancelled" } }); + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest({ genesis: OTHER_GENESIS }), dialCodec)).toEqual(NOT_GRANTED); + expect(asked).toEqual([GENESIS, OTHER_GENESIS]); + pending.resolve(true); + expect((await Promise.all(dials.slice(1))).every((bytes) => decodeReply(bytes, dialCodec).success)).toBe(true); + expect((await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).toEqual(LIMIT); + expect(transports).toHaveLength(8); + session.close(); + }); + + test("denied and failed distinct-genesis decisions fill the lifetime budget without eviction", async () => { + const asked: string[] = []; + const { session, transports } = await negotiated({ + authorize: async (genesis) => { + asked.push(genesis); + if (asked.length % 2 === 0) throw new Error("dismissed"); + return false; + }, + }); + const networks = Array.from({ length: 9 }, (_, i) => `0x${i.toString(16).padStart(2, "0").repeat(32)}` as const); + for (const genesis of networks.slice(0, 8)) { + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest({ genesis }), dialCodec)).toEqual(NOT_GRANTED); + } + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest({ genesis: networks[8]! }), dialCodec)).toEqual(LIMIT); + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest({ genesis: networks[0]! }), dialCodec)).toEqual(NOT_GRANTED); + expect(asked).toEqual(networks.slice(0, 8)); + expect(transports).toHaveLength(0); + session.close(); + }); + + test("cancelled distinct-network prompts stay bounded and close withdraws repeated waiters", async () => { + const pending = Promise.withResolvers(); + const asked: string[] = []; + const { session, transports } = await negotiated({ + authorize: (genesis) => { + asked.push(genesis); + return pending.promise; + }, + }); + const networks = Array.from({ length: 9 }, (_, i) => `0x${i.toString(16).padStart(2, "0").repeat(32)}` as const); + for (const genesis of networks.slice(0, 8)) { + const dial = session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, dialRequest({ genesis }), "cancel-prompt")); + await session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, new Uint8Array(), "cancel-prompt", MESSAGE_TYPE_CANCEL)); + expect(decodeReply(await dial, dialCodec)).toEqual({ success: false, value: { tag: "Cancelled" } }); + } + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest({ genesis: networks[8]! }), dialCodec)).toEqual(LIMIT); + // Repeated cancelled subscribers must not hold operation slots or ask again. + for (let i = 0; i < 16; i++) { + const dial = session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, dialRequest({ genesis: networks[0]! }), "retry-prompt")); + await session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, new Uint8Array(), "retry-prompt", MESSAGE_TYPE_CANCEL)); + expect(decodeReply(await dial, dialCodec)).toEqual({ success: false, value: { tag: "Cancelled" } }); + } + const waiting = Array.from({ length: 8 }, () => call(session, JAM_PEER_TRANSPORT_DIAL, + dialRequest({ genesis: networks[0]! }), dialCodec)); + session.close(); + expect(await Promise.all(waiting)).toEqual(Array.from({ length: 8 }, () => ({ success: false, value: { tag: "Denied" } }))); + pending.resolve(true); + await tick(); + expect(asked).toEqual(networks.slice(0, 8)); + expect(transports).toHaveLength(0); + }); + test("dial before the handshake is NotGranted and asks nothing", async () => { const asked: string[] = []; const session = createJamPeerTransportSession({ @@ -396,6 +485,202 @@ describe("withdrawn dials", () => { }); describe("streams", () => { + test("concurrent opens reserve the sixteen slots before transport creation settles", async () => { + const { session, transport, conn } = await dialed(); + const pending: Array<(stream: WebTransportBidirectionalStreamLike) => void> = []; + transport.createBidirectionalStream = () => { + const { promise, resolve } = Promise.withResolvers(); + pending.push(resolve); + return promise; + }; + const opens = Array.from({ length: 16 }, () => + call(session, JAM_PEER_TRANSPORT_OPEN, T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec)); + const excess = await call(session, JAM_PEER_TRANSPORT_OPEN, T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + expect(excess).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Limit" } } }); + expect(pending).toHaveLength(16); + for (const resolve of pending) resolve(fakeStream().local); + expect((await Promise.all(opens)).every((result) => result.success)).toBe(true); + session.close(); + }); + + test("an incoming stream waiting for its kind reserves a slot too", async () => { + const { session, transport, conn } = await dialed(); + const incoming = transport.peerOpen(); + await tick(); + for (let i = 0; i < 15; i++) { + const opened = await call(session, JAM_PEER_TRANSPORT_OPEN, T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + expect(opened.success).toBe(true); + } + const excess = await call(session, JAM_PEER_TRANSPORT_OPEN, T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + expect(excess).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Limit" } } }); + incoming.peerWrite(new Uint8Array([128])); + await tick(); + const events = await call(session, JAM_PEER_TRANSPORT_EVENTS, T.VersionedHostJamPeerTransportEventsRequest.enc({ tag: "V1" }), eventsCodec); + expect(events.success && events.value.value.events).toEqual([{ tag: "Accepted", value: { conn, stream: 16, kind: 128 } }]); + session.close(); + }); + + test("cancelled and closed pending opens cannot publish late transport streams", async () => { + for (const cancel of [true, false]) { + const { session, transport, conn } = await dialed(); + const pending = Promise.withResolvers(); + transport.createBidirectionalStream = () => pending.promise; + const opening = session.handleFrame(frame(JAM_PEER_TRANSPORT_OPEN, + T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), "pending-open")); + if (cancel) { + await session.handleFrame(frame(JAM_PEER_TRANSPORT_OPEN, new Uint8Array(), "pending-open", MESSAGE_TYPE_CANCEL)); + expect(decodeReply(await opening, openCodec)).toEqual({ success: false, value: { tag: "Cancelled" } }); + } else { + await call(session, JAM_PEER_TRANSPORT_CLOSE, T.VersionedHostJamPeerTransportCloseRequest.enc({ tag: "V1", value: { conn } }), closeCodec); + expect(decodeReply(await opening, openCodec)).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Closed" } } }); + } + const late = fakeStream(); + pending.resolve(late.local); + await tick(); + expect(late.sent).toEqual([]); + expect(() => late.peerWrite(new Uint8Array([0]))).toThrow(); + session.close(); + } + }); + + test("CANCEL settles blocked kind writes and message writes without waiting for the peer", async () => { + for (const duringOpen of [true, false]) { + const { session, transport, conn } = await dialed(); + const blocked = Promise.withResolvers(); + const wire = fakeStream((chunk) => duringOpen || chunk.length > 1 ? blocked.promise : Promise.resolve()); + transport.createBidirectionalStream = async () => wire.local; + const opening = session.handleFrame(frame(JAM_PEER_TRANSPORT_OPEN, + T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), "blocked")); + let request = opening; + const ids = duringOpen ? JAM_PEER_TRANSPORT_OPEN : JAM_PEER_TRANSPORT_SEND; + if (!duringOpen) { + const opened = decodeReply(await opening, openCodec); + if (!opened.success) throw new Error("open failed"); + request = session.handleFrame(frame(ids, T.VersionedHostJamPeerTransportSendRequest.enc({ + tag: "V1", value: { stream: opened.value.value.stream, message: "0x01", fin: false }, + }), "blocked")); + } + await tick(); + await session.handleFrame(frame(ids, new Uint8Array(), "blocked", MESSAGE_TYPE_CANCEL)); + expect(decodeReply(await request, S.Result(S._void, S.CallError(S._void)))).toEqual({ success: false, value: { tag: "Cancelled" } }); + blocked.resolve(); + await tick(); + session.close(); + } + }); + + test("cancelled writes retain connection quota until the underlying sink settles", async () => { + const { session, transport, conn } = await dialed(); + const blocked = Promise.withResolvers(); + const sending: Promise[] = []; + for (let i = 0; i < 3; i++) { + const wire = fakeStream((chunk) => chunk.length > 1 ? blocked.promise : Promise.resolve()); + transport.createBidirectionalStream = async () => wire.local; + const opened = await call(session, JAM_PEER_TRANSPORT_OPEN, + T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + if (!opened.success) throw new Error("open failed"); + sending.push(session.handleFrame(frame(JAM_PEER_TRANSPORT_SEND, + T.VersionedHostJamPeerTransportSendRequest.enc({ + tag: "V1", value: { stream: opened.value.value.stream, message: `0x${"00".repeat(JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES)}`, fin: false }, + }), `retained-${i}`))); + } + await tick(); + for (let i = 0; i < sending.length; i++) { + await session.handleFrame(frame(JAM_PEER_TRANSPORT_SEND, new Uint8Array(), `retained-${i}`, MESSAGE_TYPE_CANCEL)); + expect(decodeReply(await sending[i]!, sendCodec)).toEqual({ success: false, value: { tag: "Cancelled" } }); + } + transport.createBidirectionalStream = async () => fakeStream().local; + const opened = await call(session, JAM_PEER_TRANSPORT_OPEN, + T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + if (!opened.success) throw new Error("open failed"); + const request = T.VersionedHostJamPeerTransportSendRequest.enc({ + tag: "V1", value: { stream: opened.value.value.stream, message: `0x${"00".repeat(JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES)}`, fin: false }, + }); + expect(await call(session, JAM_PEER_TRANSPORT_SEND, request, sendCodec)) + .toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Limit" } } }); + blocked.resolve(); + await tick(); + expect((await call(session, JAM_PEER_TRANSPORT_SEND, request, sendCodec)).success).toBe(true); + session.close(); + }); + + test("receive backpressure counts frames across streams and resumes after recv releases space", async () => { + const { session, transport, conn } = await dialed(); + const ids: number[] = []; + const length = JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION / 4 - 4; + for (let i = 0; i < 2; i++) { + const opened = await call(session, JAM_PEER_TRANSPORT_OPEN, T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + if (!opened.success) throw new Error("open failed"); + ids.push(opened.value.value.stream); + for (let message = 0; message < (i === 0 ? 4 : 1); message++) { + const bytes = new Uint8Array((i === 0 ? length : 1) + 4); + new DataView(bytes.buffer).setUint32(0, bytes.length - 4, true); + transport.streams[i]!.peerWrite(bytes); + } + await tick(); + } + const receive = (stream: number) => call(session, JAM_PEER_TRANSPORT_RECV, + T.VersionedHostJamPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES } }), recvCodec); + expect(await receive(ids[1]!)).toEqual({ success: true, value: { tag: "V1", value: { message: undefined, fin: false, reset: false } } }); + const first = await receive(ids[0]!); + expect(first.success && first.value.value.message?.length).toBe(2 + length * 2); + await tick(); + expect(await receive(ids[1]!)).toEqual({ success: true, value: { tag: "V1", value: { message: "0x00", fin: false, reset: false } } }); + session.close(); + }); + + test("FIN excludes concurrent sends before its blocked write completes", async () => { + const { session, transport, conn } = await dialed(); + const blocked = Promise.withResolvers(); + const wire = fakeStream((chunk) => chunk.length > 1 ? blocked.promise : Promise.resolve()); + transport.createBidirectionalStream = async () => wire.local; + const opened = await call(session, JAM_PEER_TRANSPORT_OPEN, + T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + if (!opened.success) throw new Error("open failed"); + const stream = opened.value.value.stream; + const finishing = call(session, JAM_PEER_TRANSPORT_SEND, + T.VersionedHostJamPeerTransportSendRequest.enc({ tag: "V1", value: { stream, message: "0x01", fin: true } }), sendCodec); + const following = call(session, JAM_PEER_TRANSPORT_SEND, + T.VersionedHostJamPeerTransportSendRequest.enc({ tag: "V1", value: { stream, message: "0x02", fin: false } }), sendCodec); + blocked.resolve(); + expect((await finishing).success).toBe(true); + expect(await following).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Closed" } } }); + expect(wire.sent).toHaveLength(2); + session.close(); + }); + + test("a peer withholding one kind byte does not block other incoming streams", async () => { + const { session, transport, conn } = await dialed(); + transport.peerOpen(); + const ready = transport.peerOpen(); + ready.peerWrite(new Uint8Array([128])); + await tick(); + const events = await call(session, JAM_PEER_TRANSPORT_EVENTS, + T.VersionedHostJamPeerTransportEventsRequest.enc({ tag: "V1" }), eventsCodec); + expect(events.success && events.value.value.events).toEqual([ + { tag: "Accepted", value: { conn, stream: 1, kind: 128 } }, + ]); + session.close(); + }); + + test("a final send releases a stream whose receive side was already consumed", async () => { + const { session, transport, conn } = await dialed(); + for (let i = 0; i < 17; i++) { + const opened = await call(session, JAM_PEER_TRANSPORT_OPEN, + T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + if (!opened.success) throw new Error("completed stream retained its slot"); + const stream = opened.value.value.stream; + transport.streams[i]!.peerFin(); + await tick(); + expect(await call(session, JAM_PEER_TRANSPORT_RECV, + T.VersionedHostJamPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 } }), recvCodec)) + .toEqual({ success: true, value: { tag: "V1", value: { message: undefined, fin: true, reset: false } } }); + expect((await call(session, JAM_PEER_TRANSPORT_SEND, + T.VersionedHostJamPeerTransportSendRequest.enc({ tag: "V1", value: { stream, message: "0x", fin: true } }), sendCodec)).success).toBe(true); + } + session.close(); + }); + test("open sends the kind byte; send frames with a u32-LE prefix; recv unframes", async () => { const { session, transport, conn } = await dialed(); const open = await call(session, JAM_PEER_TRANSPORT_OPEN, T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 128 } }), openCodec); diff --git a/js/packages/truapi/src/jam-peer-transport.ts b/js/packages/truapi/src/jam-peer-transport.ts index c7e67dc325..4ccf5831d5 100644 --- a/js/packages/truapi/src/jam-peer-transport.ts +++ b/js/packages/truapi/src/jam-peer-transport.ts @@ -70,6 +70,8 @@ export interface JamPeerTransportOptions { * `RemotePermission::JamPeers` runtime permission. The session asks at most * once per genesis and concurrent dials share the pending answer; `false` or * a rejection answers `NotGranted` for the rest of the session. + * At most eight distinct genesis decisions, including pending/refused ones, + * are retained per session; a new ninth genesis answers `Limit`. */ authorize(genesis: string): Promise; /** Host transport injection; defaults to the browser `WebTransport` constructor. */ @@ -154,9 +156,9 @@ interface PeerStream { id: number; conn: PeerConnection; writer: WritableStreamDefaultWriter; - reader: ReadableStreamDefaultReader; - /** Unparsed receive bytes. */ - rx: Uint8Array; + reader: ReadableStreamBYOBReader; + /** Reserved receive bytes, including each message's length prefix. */ + rxBytes: number; /** Complete messages not yet delivered by `recv`. */ messages: Uint8Array[]; fin: boolean; @@ -164,17 +166,47 @@ interface PeerStream { /** `recv` reported `fin` with an empty queue; further reads are `Closed`. */ rxConsumed: boolean; txClosed: boolean; - /** Bytes of frames handed to the writer that have not been accepted yet. */ - txPending: number; + onClose: Set<() => void>; } interface PeerConnection { id: number; transport: WebTransportLike; streams: Map; + opening: number; + rxBytes: number; + /** Outgoing frames retain their reservation until writes settle, even after stream removal. */ + txBytes: number; + rxWaiters: Set<() => void>; + onClose: Set<() => void>; closed: boolean; } +interface PendingRequest { + method: number; + response?: Uint8Array; + promise: Promise; + withdraw(response: Uint8Array): void; +} + +/** Fill exactly one header/payload without reading or allocating the following message. */ +async function readExact(reader: ReadableStreamBYOBReader, length: number): Promise { + let bytes = new Uint8Array(length); + let offset = 0; + while (offset < length) { + const { value, done } = await reader.read(bytes.subarray(offset)); + if (value !== undefined) { + bytes = new Uint8Array(value.buffer); + offset += value.byteLength; + } + if (done) { + if (offset !== 0) throw new Error("Truncated peer message"); + return undefined; + } + } + return bytes; +} + /** * Create the browser JamPeerTransport endpoint for one execution. Every `dial` * is authorized for its genesis through `options.authorize` before anything @@ -185,22 +217,44 @@ interface PeerConnection { * permission decision is remembered either way, so a retry does not ask * again. The host must fence late replies against execution stop or * replacement. + * Pending permission/handshake dials share the eight-connection admission + * budget with established connections, before any permission request is made. */ export function createJamPeerTransportSession(options: JamPeerTransportOptions): JamPeerTransportSession { - const decisions = new Map>(); - const authorized = (genesis: string): Promise => { + const decisions = new Map void>; + }>(); + const authorized = async (genesis: string, withdrawn: Promise): Promise => { let decision = decisions.get(genesis); if (decision === undefined) { - decision = (async (): Promise => { + decision = { waiters: new Set() }; + decisions.set(genesis, decision); + const current = decision; + void (async () => { + let granted = false; try { - return (await options.authorize(genesis)) === true; + granted = (await options.authorize(genesis)) === true; } catch { - return false; + // A failed or dismissed permission request grants nothing. } + if (closed) return; + current.result = granted; + for (const settle of current.waiters) settle(granted); + current.waiters.clear(); })(); - decisions.set(genesis, decision); } - return decision; + if (decision.result !== undefined) return decision.result; + // Only live dials subscribe; repeated cancellation cannot accumulate reactions + // on the retained, potentially indefinitely pending permission request. + let settle!: (granted: boolean) => void; + const answer = new Promise((resolve) => { settle = resolve; }); + decision.waiters.add(settle); + try { + return await Promise.race([answer, withdrawn]); + } finally { + decision.waiters.delete(settle); + } }; const connect = options.connect ?? @@ -210,13 +264,14 @@ export function createJamPeerTransportSession(options: JamPeerTransportOptions): }) as unknown as WebTransportLike); const now = options.now ?? ((): number => Math.floor(Date.now() / 1000)); const dialTimeoutMs = options.dialTimeoutMs ?? JAM_PEER_TRANSPORT_DIAL_TIMEOUT_MS; - /** In-flight dials by request id; CANCEL or `close` withdraws one with its reply. */ - const pendingDials = new Map void>(); + /** Every asynchronous request remains addressable until its reply is settled. */ + const pendingRequests = new Map(); let closed = false; let negotiated = false; let nextConn = 1; let nextStream = 1; const connections = new Map(); + let pendingDialSlots = 0; const streams = new Map(); const events: T.JamPeerTransportEvent[] = []; @@ -224,10 +279,40 @@ export function createJamPeerTransportSession(options: JamPeerTransportOptions): if (events.length < MAX_PENDING_EVENTS) events.push(event); }; + const wakeReaders = (conn: PeerConnection): void => { + for (const wake of conn.rxWaiters) wake(); + conn.rxWaiters.clear(); + }; + + const releaseReceived = (stream: PeerStream): void => { + stream.conn.rxBytes -= stream.rxBytes; + stream.rxBytes = 0; + stream.messages.length = 0; + wakeReaders(stream.conn); + }; + + const abortReceive = (stream: PeerStream): void => { + stream.reset = true; + for (const close of stream.onClose) close(); + stream.onClose.clear(); + releaseReceived(stream); + void stream.writer.abort().catch(() => undefined); + void stream.reader.cancel().catch(() => undefined); + }; + + const abortBidi = (bidi: WebTransportBidirectionalStreamLike): void => { + void bidi.writable.abort().catch(() => undefined); + void bidi.readable.cancel().catch(() => undefined); + }; + const dropStream = (stream: PeerStream, abort: boolean): void => { streams.delete(stream.id); stream.conn.streams.delete(stream.id); + for (const close of stream.onClose) close(); + stream.onClose.clear(); + releaseReceived(stream); if (abort) { + stream.reset = true; void stream.writer.abort().catch(() => undefined); void stream.reader.cancel().catch(() => undefined); } @@ -237,6 +322,9 @@ export function createJamPeerTransportSession(options: JamPeerTransportOptions): if (conn.closed) return; conn.closed = true; connections.delete(conn.id); + for (const close of conn.onClose) close(); + conn.onClose.clear(); + wakeReaders(conn); for (const stream of [...conn.streams.values()]) dropStream(stream, true); try { conn.transport.close(); @@ -246,91 +334,111 @@ export function createJamPeerTransportSession(options: JamPeerTransportOptions): pushEvent({ tag: "ConnClosed", value: { conn: conn.id } }); }; - /** Parse complete `u32`-LE framed messages out of `stream.rx`. */ - const unframe = (stream: PeerStream): void => { - while (stream.rx.length >= 4) { - const view = new DataView(stream.rx.buffer, stream.rx.byteOffset, stream.rx.byteLength); - const length = view.getUint32(0, true); - if (length > JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES) { - stream.reset = true; - void stream.writer.abort().catch(() => undefined); - void stream.reader.cancel().catch(() => undefined); - stream.rx = new Uint8Array(); - return; + const reserveReceive = async (stream: PeerStream, bytes: number): Promise => { + while (!stream.reset && !stream.conn.closed) { + if (stream.conn.rxBytes + stream.conn.txBytes + bytes <= JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION) { + stream.rxBytes += bytes; + stream.conn.rxBytes += bytes; + return true; } - if (stream.rx.length < 4 + length) return; - stream.messages.push(stream.rx.slice(4, 4 + length)); - stream.rx = stream.rx.slice(4 + length); + // The package targets ES2022, before Promise.withResolvers. + await new Promise((resolve) => stream.conn.rxWaiters.add(resolve)); } + return false; }; - const pump = async (stream: PeerStream, initial: Uint8Array): Promise => { - stream.rx = initial; - unframe(stream); + const pump = async (stream: PeerStream): Promise => { try { - while (!stream.reset) { - const { value, done } = await stream.reader.read(); - if (done) break; - const next = new Uint8Array(stream.rx.length + value.length); - next.set(stream.rx); - next.set(value, stream.rx.length); - stream.rx = next; - unframe(stream); - } - if (!stream.reset) { - stream.fin = true; - if (stream.rx.length !== 0) stream.reset = true; - if (streams.has(stream.id)) pushEvent({ tag: "StreamFin", value: { stream: stream.id } }); + while (!stream.reset && !stream.conn.closed) { + if (!await reserveReceive(stream, 4)) return; + const header = await readExact(stream.reader, 4); + if (stream.reset || stream.conn.closed) return; + if (header === undefined) { + stream.rxBytes -= 4; + stream.conn.rxBytes -= 4; + wakeReaders(stream.conn); + stream.fin = true; + if (streams.has(stream.id)) pushEvent({ tag: "StreamFin", value: { stream: stream.id } }); + return; + } + const length = new DataView(header.buffer, header.byteOffset, 4).getUint32(0, true); + if (length > JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES) { + abortReceive(stream); + return; + } + if (!await reserveReceive(stream, length)) return; + const message = await readExact(stream.reader, length); + if (stream.reset || stream.conn.closed) return; + if (message === undefined) throw new Error("Truncated peer message"); + stream.messages.push(message); } } catch { - stream.reset = true; + if (!stream.reset && !stream.conn.closed) abortReceive(stream); } }; - const register = (conn: PeerConnection, bidi: WebTransportBidirectionalStreamLike, initial: Uint8Array): PeerStream => { + const register = (conn: PeerConnection, bidi: WebTransportBidirectionalStreamLike, reader = bidi.readable.getReader({ mode: "byob" })): PeerStream => { const stream: PeerStream = { id: nextStream++, conn, writer: bidi.writable.getWriter(), - reader: bidi.readable.getReader(), - rx: new Uint8Array(), + reader, + rxBytes: 0, messages: [], fin: false, reset: false, rxConsumed: false, txClosed: false, - txPending: 0, + onClose: new Set(), }; streams.set(stream.id, stream); conn.streams.set(stream.id, stream); - void pump(stream, initial); + void pump(stream); return stream; }; const acceptLoop = async (conn: PeerConnection): Promise => { const incoming = conn.transport.incomingBidirectionalStreams.getReader(); + const stop = (): void => { void incoming.cancel().catch(() => undefined); }; + conn.onClose.add(stop); try { while (!conn.closed) { const { value: bidi, done } = await incoming.read(); - if (done || conn.closed) break; - if (conn.streams.size >= JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION) { - void bidi.writable.abort().catch(() => undefined); - void bidi.readable.cancel().catch(() => undefined); + if (done) break; + if (conn.closed || conn.streams.size + conn.opening >= JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION) { + abortBidi(bidi); continue; } - // The peer's first byte is the stream kind; anything after it is message data. - const reader = bidi.readable.getReader(); - const first = await reader.read(); - reader.releaseLock(); - if (first.done || first.value.length === 0 || conn.closed) { + conn.opening++; + const reader = bidi.readable.getReader({ mode: "byob" }); + const cancel = (): void => { + void reader.cancel().catch(() => undefined); void bidi.writable.abort().catch(() => undefined); - continue; - } - const stream = register(conn, bidi, first.value.subarray(1)); - pushEvent({ tag: "Accepted", value: { conn: conn.id, stream: stream.id, kind: first.value[0]! } }); + }; + conn.onClose.add(cancel); + // A peer that withholds one kind byte must not block the other reserved streams. + void (async () => { + try { + const kind = await readExact(reader, 1); + if (kind === undefined || conn.closed || events.length >= MAX_PENDING_EVENTS) { + cancel(); + return; + } + const stream = register(conn, bidi, reader); + pushEvent({ tag: "Accepted", value: { conn: conn.id, stream: stream.id, kind: kind[0]! } }); + } catch { + cancel(); + } finally { + conn.opening--; + conn.onClose.delete(cancel); + } + })(); } } catch { // The connection is closing; `closed` handling reports it. + } finally { + conn.onClose.delete(stop); + incoming.releaseLock(); } }; @@ -340,7 +448,17 @@ export function createJamPeerTransportSession(options: JamPeerTransportOptions): * so nothing it opens outlives it or holds a connection slot. */ const dial = async (request: T.HostJamPeerTransportDialRequest, withdrawn: Promise): Promise => { - const granted = await Promise.race([authorized(request.genesis), withdrawn]); + if (connections.size + pendingDialSlots >= JAM_PEER_TRANSPORT_MAX_CONNECTIONS || + (!decisions.has(request.genesis) && decisions.size >= JAM_PEER_TRANSPORT_MAX_CONNECTIONS)) { + return domain(dialResult, "Limit"); + } + pendingDialSlots++; + let granted: boolean | Uint8Array; + try { + granted = await authorized(request.genesis, withdrawn); + } finally { + pendingDialSlots--; + } if (granted instanceof Uint8Array) return granted; if (!granted) return domain(dialResult, "NotGranted"); if (closed) return frameworkResult.enc({ success: false, value: { tag: "Denied" } }); @@ -355,7 +473,10 @@ export function createJamPeerTransportSession(options: JamPeerTransportOptions): } catch { return domain(dialResult, "Unreachable"); } - const conn: PeerConnection = { id: nextConn++, transport, streams: new Map(), closed: false }; + const conn: PeerConnection = { + id: nextConn++, transport, streams: new Map(), closed: false, + opening: 0, rxBytes: 0, txBytes: 0, rxWaiters: new Set(), onClose: new Set(), + }; connections.set(conn.id, conn); const failure = await Promise.race([ transport.ready.then( @@ -384,61 +505,110 @@ export function createJamPeerTransportSession(options: JamPeerTransportOptions): return ok(dialResult, { tag: "V1", value: { conn: conn.id } }); }; - /** Run one dial frame: register it for CANCEL and arm its deadline. */ - const dialFrame = async (requestId: string, request: T.HostJamPeerTransportDialRequest): Promise => { - // Executor form: this package's lib target predates Promise.withResolvers. - let withdraw!: (reply: Uint8Array) => void; - const withdrawn = new Promise((resolve) => { - withdraw = resolve; - }); - pendingDials.set(requestId, withdraw); - const timer = setTimeout(() => withdraw(domain(dialResult, "Unreachable")), dialTimeoutMs); + const requestFrame = async ( + request: ProtocolMessage, + run: (pending: PendingRequest) => Promise, + timeout?: number, + ): Promise => { + // Executor form is required by this package's ES2022 target. + let resolve!: (response: Uint8Array) => void; + const promise = new Promise((settle) => { resolve = settle; }); + const pending: PendingRequest = { + method: request.payload.methodId, + promise, + withdraw(response) { + if (pending.response !== undefined) return; + pending.response = response; + resolve(response); + }, + }; + pendingRequests.set(request.requestId, pending); + const timer = timeout === undefined ? undefined : + setTimeout(() => pending.withdraw(domain(dialResult, "Unreachable")), timeout); try { - return await dial(request, withdrawn); + return await run(pending); } finally { clearTimeout(timer); - pendingDials.delete(requestId); + pendingRequests.delete(request.requestId); } }; - const open = async (request: T.HostJamPeerTransportOpenRequest): Promise => { + const open = async (request: T.HostJamPeerTransportOpenRequest, pending: PendingRequest): Promise => { const conn = connections.get(request.conn); if (conn === undefined || conn.closed) return domain(openResult, "Closed"); - if (conn.streams.size >= JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION) return domain(openResult, "Limit"); + if (conn.streams.size + conn.opening >= JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION) return domain(openResult, "Limit"); + conn.opening++; + let reserved = true; + const close = (): void => pending.withdraw(domain(openResult, "Closed")); + conn.onClose.add(close); + let stream: PeerStream | undefined; try { - const bidi = await conn.transport.createBidirectionalStream(); - const stream = register(conn, bidi, new Uint8Array()); - await stream.writer.write(new Uint8Array([request.kind])); + const opening = conn.transport.createBidirectionalStream(); + void opening.then((bidi) => { + if (pending.response !== undefined) abortBidi(bidi); + }, () => undefined); + const bidi = await Promise.race([opening, pending.promise]); + if (bidi instanceof Uint8Array) return bidi; + if (pending.response !== undefined || conn.closed) { + abortBidi(bidi); + return pending.response ?? domain(openResult, "Closed"); + } + conn.opening--; + reserved = false; + stream = register(conn, bidi); + stream.onClose.add(close); + const result = await Promise.race([stream.writer.write(new Uint8Array([request.kind])), pending.promise]); + if (result instanceof Uint8Array) return result; return ok(openResult, { tag: "V1", value: { stream: stream.id } }); } catch { + if (stream !== undefined) dropStream(stream, true); return domain(openResult, "Closed"); + } finally { + if (reserved) conn.opening--; + conn.onClose.delete(close); + stream?.onClose.delete(close); + if (pending.response !== undefined && stream !== undefined) dropStream(stream, true); } }; - const send = async (request: T.HostJamPeerTransportSendRequest): Promise => { + const send = async (request: T.HostJamPeerTransportSendRequest, pending: PendingRequest): Promise => { const stream = streams.get(request.stream); if (stream === undefined || stream.txClosed || stream.reset || stream.conn.closed) return domain(sendResult, "Closed"); const message = S.hexToBytes(request.message); if (message.length > JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES) return domain(sendResult, "TooLarge"); - let pending = 0; - for (const other of stream.conn.streams.values()) pending += other.txPending; - if (pending + message.length + 4 > JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION) return domain(sendResult, "Limit"); + if (stream.conn.rxBytes + stream.conn.txBytes + message.length + 4 > JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION) return domain(sendResult, "Limit"); const frame = new Uint8Array(4 + message.length); new DataView(frame.buffer).setUint32(0, message.length, true); frame.set(message, 4); - stream.txPending += frame.length; + stream.conn.txBytes += frame.length; + const close = (): void => pending.withdraw(domain(sendResult, "Closed")); + stream.onClose.add(close); + // Reserve FIN before yielding, so a concurrent send cannot pass it. + if (request.fin) stream.txClosed = true; try { - await stream.writer.write(frame); + // Cancellation settles the guest request immediately, but a browser + // write can still retain its frame until the underlying sink settles. + const writing = stream.writer.write(frame).finally(() => { + stream.conn.txBytes -= frame.length; + wakeReaders(stream.conn); + }); + let result = await Promise.race([writing, pending.promise]); + if (result instanceof Uint8Array) return result; if (request.fin) { - stream.txClosed = true; - await stream.writer.close(); + result = await Promise.race([stream.writer.close(), pending.promise]); + if (result instanceof Uint8Array) return result; + if (stream.rxConsumed) { + stream.onClose.delete(close); + dropStream(stream, false); + } } return ok(sendResult, { tag: "V1" }); } catch { stream.txClosed = true; return domain(sendResult, "Closed"); } finally { - stream.txPending -= frame.length; + stream.onClose.delete(close); + if (pending.response !== undefined) dropStream(stream, true); } }; @@ -448,14 +618,14 @@ export function createJamPeerTransportSession(options: JamPeerTransportOptions): const next = stream.messages[0]; if (next !== undefined && next.length > request.max) { // The guest cannot take this message; treat it as a protocol violation. - stream.messages.length = 0; - stream.reset = true; - void stream.writer.abort().catch(() => undefined); - void stream.reader.cancel().catch(() => undefined); + abortReceive(stream); } let message: S.HexString | undefined; if (!stream.reset && next !== undefined) { stream.messages.shift(); + stream.rxBytes -= next.length + 4; + stream.conn.rxBytes -= next.length + 4; + wakeReaders(stream.conn); message = S.bytesToHex(next); } const drained = stream.messages.length === 0; @@ -489,10 +659,8 @@ export function createJamPeerTransportSession(options: JamPeerTransportOptions): if (request.payload.traitId !== JAM_PEER_TRANSPORT_DIAL.trait || request.payload.value.length !== 0) { throw new Error("Invalid cancellation frame"); } - // Only a dial can outlast one host tick: it may wait on a permission - // prompt and a handshake. The dial itself answers `Cancelled`; a - // CANCEL naming nothing in flight lost the race and is dropped. - if (hasIds(request, JAM_PEER_TRANSPORT_DIAL)) pendingDials.get(request.requestId)?.(cancelledReply); + const pending = pendingRequests.get(request.requestId); + if (pending?.method === request.payload.methodId) pending.withdraw(cancelledReply); return new Uint8Array(); } if (request.payload.messageType !== MESSAGE_TYPE_REQUEST) { @@ -517,22 +685,20 @@ export function createJamPeerTransportSession(options: JamPeerTransportOptions): } const malformed = (): Uint8Array => reply(request, frameworkResult.enc({ success: false, value: { tag: "MalformedFrame", value: { reason: "invalid peer-transport request" } } })); + if (pendingRequests.has(request.requestId)) return new Uint8Array(); try { if (hasIds(request, JAM_PEER_TRANSPORT_DIAL)) { const value = exact(T.VersionedHostJamPeerTransportDialRequest, request.payload.value).value; if (!negotiated) return reply(request, domain(dialResult, "NotGranted")); - // Two live dials sharing an id leave neither addressable by CANCEL; - // like the core dispatcher, the second is dropped unanswered. - if (pendingDials.has(request.requestId)) return new Uint8Array(); - return reply(request, await dialFrame(request.requestId, value)); + return reply(request, await requestFrame(request, (pending) => dial(value, pending.promise), dialTimeoutMs)); } if (hasIds(request, JAM_PEER_TRANSPORT_OPEN)) { const value = exact(T.VersionedHostJamPeerTransportOpenRequest, request.payload.value).value; - return reply(request, negotiated ? await open(value) : domain(openResult, "NotGranted")); + return reply(request, negotiated ? await requestFrame(request, (pending) => open(value, pending)) : domain(openResult, "NotGranted")); } if (hasIds(request, JAM_PEER_TRANSPORT_SEND)) { const value = exact(T.VersionedHostJamPeerTransportSendRequest, request.payload.value).value; - return reply(request, negotiated ? await send(value) : domain(sendResult, "Closed")); + return reply(request, negotiated ? await requestFrame(request, (pending) => send(value, pending)) : domain(sendResult, "Closed")); } if (hasIds(request, JAM_PEER_TRANSPORT_RECV)) { const value = exact(T.VersionedHostJamPeerTransportRecvRequest, request.payload.value).value; @@ -559,8 +725,10 @@ export function createJamPeerTransportSession(options: JamPeerTransportOptions): close() { closed = true; const denied = frameworkResult.enc({ success: false, value: { tag: "Denied" } }); - for (const withdraw of [...pendingDials.values()]) withdraw(denied); + for (const pending of pendingRequests.values()) pending.withdraw(denied); for (const conn of [...connections.values()]) dropConnection(conn); + for (const decision of decisions.values()) decision.waiters.clear(); + decisions.clear(); events.length = 0; }, }; diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index b3cae12f18..177edfa772 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -216,20 +216,51 @@ Native product runtimes (iOS, Android, CLI) serve `JamPeerTransport` (trait Every `dial` first requires `RemotePermission::JamPeers { genesis }` through the flow above. The connection asks once per genesis: concurrent dials wait for the same prompt, a refusal stays `NotGranted` for the connection, and the answer is -persisted even when every dial waiting on it has given up. +persisted even when every dial waiting on it has timed out. Revoking or dropping +the session instead cancels its pending permission checks and transport operations. + +Pending dials reserve from the eight-connection budget before awaiting permission, +alongside retained connection handles. The session remembers at most eight distinct +full-genesis decisions, including pending and denied decisions; a new ninth +genesis returns `Limit` without prompting or evicting a prior decision. Cancelled, +timed-out, or dropped dial futures release their pending reservation and subscription. +The one bounded permission task per genesis remains until its answer or session stop. - A dial answers within 10 seconds, prompt included. One still waiting then answers `Unreachable`, a cancelled one `Cancelled`, and what it would have - opened is dropped without holding one of the 8 connection slots. + opened is dropped without holding one of the 8 connection slots. Native + closed connection handles retain their slot until the guest calls `close`, + so their remaining streams and queued data cannot bypass the cap. - The ALPN is `jam_peer_transport::alpn(genesis)`, and the peer certificate must carry the Ed25519 key the dial names. The P-256 key is for WebTransport hosts and is ignored. - The first granted dial creates the endpoint, so a refused product binds no socket. Disposing the connection closes every peer connection, and later calls are `Denied`. +- Stream opens reserve one of 16 slots before waiting for the transport. + Cancellation and session closure cannot publish late stream handles. +- Incoming and outgoing length-prefixed messages share a 4 MiB per-connection + reservation budget. Headers and empty messages consume space too; readers + wait for capacity before allocating payloads, and draining/resetting streams + releases capacity. Individual payloads remain limited to 1 MiB. The browser core keeps the trait's `NotGranted` defaults, because its JavaScript session answers trait 111 before frames reach the core. +Browser dials share the eight-connection limit with established connections while +awaiting permission or the handshake. Its execution-local cache holds at most +eight distinct genesis decisions, counting pending/refused decisions too; a new +ninth genesis returns `Limit` without evicting any remembered decision. +Cancelling a dial releases its operation slot and removes its subscription to a +pending decision. The shared decision remains available to retries until stop. + +The browser uses WebTransport's readable byte streams with BYOB reads to reserve +space before receiving each payload; its cancellation path handles blocked opens +and writes as well as dials. WebTransport negotiates HTTP/3, not the native +genesis-prefix ALPN. The current PolkaJAM CONNECT endpoint has no additional +genesis negotiation. On both platforms, the full genesis keys permission +decisions and TLS pins the caller-supplied peer key; neither proves validator +membership or makes received chain data trustworthy. Guests must verify it. + `cargo test -p truapi --features mock --test live_jam_test_instance -- --include-ignored` dials JAM-TEST-INSTANCE through a product runtime. diff --git a/rust/crates/truapi/src/api/jam_peer_transport.rs b/rust/crates/truapi/src/api/jam_peer_transport.rs index e468ffccc9..0ee759f446 100644 --- a/rust/crates/truapi/src/api/jam_peer_transport.rs +++ b/rust/crates/truapi/src/api/jam_peer_transport.rs @@ -28,9 +28,10 @@ use crate::{CallContext, CallError, v01, wire, wire_trait}; #[wire_trait(id = 111)] #[crate::async_trait] pub trait JamPeerTransport: Send + Sync { - /// Dial one peer. The host builds the ALPN from `genesis` and requires the - /// peer certificate to carry `ed25519` (QUIC) or to hash to the - /// certificate derived from `p256` (WebTransport). + /// Dial one peer. Native QUIC builds the ALPN from `genesis` and requires + /// the peer certificate to carry `ed25519`. WebTransport negotiates + /// HTTP/3 and pins the certificate derived from `p256`. These checks + /// authenticate the caller-supplied peer identity, not chain membership. /// /// ```ts /// const result = await truapi.jamPeerTransport.dial({ diff --git a/rust/crates/truapi/src/jam_peer_transport.rs b/rust/crates/truapi/src/jam_peer_transport.rs index 0f988d7e8d..c0c12b5cf0 100644 --- a/rust/crates/truapi/src/jam_peer_transport.rs +++ b/rust/crates/truapi/src/jam_peer_transport.rs @@ -8,10 +8,11 @@ //! Anything short of a grant answers //! [`NotGranted`](truapi::latest::HostJamPeerTransportDialError::NotGranted). //! -//! The host also owns the transport: it builds the JAMNP-S ALPN from the -//! genesis ([`alpn`]), verifies the peer certificate against the identity the -//! guest named, frames messages and enforces the `JAM_PEER_TRANSPORT_MAX_*` caps -//! from `truapi::latest`. +//! The host also owns the transport: native QUIC builds the JAMNP-S ALPN from +//! the first four genesis bytes ([`alpn`]) and pins the guest-named Ed25519 +//! identity. WebTransport uses HTTP/3 and a guest-named P-256 identity. +//! Neither authenticates chain membership. The host frames messages and +//! enforces the `JAM_PEER_TRANSPORT_MAX_*` caps from `truapi::latest`. //! //! Native product runtimes serve `JamPeerTransport` themselves over JAMNP-S //! QUIC, one endpoint per product connection. The browser core keeps the @@ -40,6 +41,7 @@ pub struct InvalidGenesis; /// The JAMNP-S ALPN protocol id for `genesis`: /// `jamnp-s/1/`. +/// This protocol selector is not a cryptographic chain-membership proof. pub fn alpn(genesis: &[u8; 32]) -> String { let mut alpn = String::with_capacity(ALPN_PREFIX.len() + 8); alpn.push_str(ALPN_PREFIX); diff --git a/rust/crates/truapi/src/jam_peer_transport/quic.rs b/rust/crates/truapi/src/jam_peer_transport/quic.rs index 281d82d6fb..5d8fb542b0 100644 --- a/rust/crates/truapi/src/jam_peer_transport/quic.rs +++ b/rust/crates/truapi/src/jam_peer_transport/quic.rs @@ -14,6 +14,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; +use futures::stream::{FuturesUnordered, StreamExt}; use parking_lot::Mutex; use tokio::sync::mpsc; use tokio::task::JoinHandle; @@ -104,14 +105,48 @@ pub(super) struct Received { pub(super) reset: bool, } +/// A frame owns its quota until delivered, flushed, or dropped on any error. +struct Reservation { + buffered: Arc, + bytes: usize, +} + +impl Reservation { + fn new(buffered: &Arc, bytes: usize) -> Option { + buffered + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| { + used.checked_add(bytes) + .filter(|&total| total <= MAX_BUFFERED_BYTES_PER_CONNECTION) + }) + .ok()?; + Some(Self { + buffered: buffered.clone(), + bytes, + }) + } +} + +impl Drop for Reservation { + fn drop(&mut self) { + self.buffered.fetch_sub(self.bytes, Ordering::AcqRel); + } +} + +struct Incoming { + bytes: Vec, + _reservation: Reservation, +} + struct Outgoing { bytes: Vec, fin: bool, + _reservation: Reservation, } struct Conn { quic: quinn::Connection, streams: Vec, + opening: usize, buffered: Arc, closed: bool, task: JoinHandle<()>, @@ -119,7 +154,7 @@ struct Conn { struct Stream { conn: u32, - inbox: VecDeque>, + inbox: VecDeque, fin: bool, reset: bool, send_open: bool, @@ -165,10 +200,6 @@ impl Inner { } if let Some(conn) = self.conns.get_mut(&entry.conn) { conn.streams.retain(|&id| id != stream); - conn.buffered.fetch_sub( - entry.inbox.iter().map(Vec::len).sum::(), - Ordering::AcqRel, - ); } } } @@ -196,6 +227,61 @@ impl Drop for DialSlot<'_> { } } +/// Both local opens waiting for credit and incoming streams awaiting a kind +/// byte hold a slot until they register or are cancelled. +struct StreamSlot { + shared: Shared, + conn: u32, + armed: bool, +} + +impl StreamSlot { + fn reserve(shared: &Shared, inner: &mut Inner, conn: u32) -> Result { + let entry = inner.conns.get_mut(&conn).filter(|entry| !entry.closed) + .ok_or(OpenError::Closed)?; + if entry.streams.len() + entry.opening >= MAX_STREAMS_PER_CONNECTION { + return Err(OpenError::Limit); + } + entry.opening += 1; + Ok(Self { shared: shared.clone(), conn, armed: true }) + } + + fn commit(mut self, inner: &mut Inner) { + if let Some(entry) = inner.conns.get_mut(&self.conn) { + entry.opening -= 1; + } + self.armed = false; + } +} + +impl Drop for StreamSlot { + fn drop(&mut self) { + if self.armed { + if let Some(entry) = self.shared.lock().conns.get_mut(&self.conn) { + entry.opening -= 1; + } + } + } +} + +/// Dropping an awaiting call must cancel, not detach, its driver task. +struct AbortOnDrop(JoinHandle); + +impl Drop for AbortOnDrop { + fn drop(&mut self) { + self.0.abort(); + } +} + +/// Quinn implicitly finishes a dropped sender; cancellation must reset it. +struct ResetOnDrop(quinn::SendStream); + +impl Drop for ResetOnDrop { + fn drop(&mut self) { + let _ = self.0.reset(0u32.into()); + } +} + /// Failure to bring up the endpoint. #[derive(Debug, thiserror::Error)] pub(super) enum TransportError { @@ -232,7 +318,14 @@ impl Transport { let endpoint = { let _guard = runtime.enter(); quinn::Endpoint::client(SocketAddr::from((Ipv6Addr::UNSPECIFIED, 0))) - .map_err(TransportError::Bind)? + }; + let endpoint = match endpoint { + Ok(endpoint) => endpoint, + Err(error) => { + // Construction may be called from an async runtime too. + runtime.shutdown_background(); + return Err(TransportError::Bind(error)); + } }; tracing::debug!( identity = %peer_id::ed25519_text(identity.public()), @@ -267,13 +360,25 @@ impl Transport { timeout: Duration, future: impl Future + Send + 'static, ) -> Option { - self.runtime() - .spawn(async move { tokio::time::timeout(timeout, future).await.ok() }) + let mut task = AbortOnDrop(self.runtime().spawn(async move { + tokio::time::timeout(timeout, future).await.ok() + })); + (&mut task.0) .await .ok() .flatten() } + /// Count permission-waiting dials against retained connection handles. + /// Keep the connection lock through reservation so a completing handshake + /// cannot fall between the connection and pending-count snapshots. + pub(super) fn reserve_pending_dial(&self, pending: &AtomicUsize) -> bool { + let inner = self.shared.lock(); + pending.fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| { + (inner.conns.len() + used < MAX_CONNECTIONS).then_some(used + 1) + }).is_ok() + } + /// Connect to one peer, requiring its certificate to carry `ed25519`, /// within [`DIAL_TIMEOUT`]. pub(super) async fn dial(&self, dial: &Dial) -> Result { @@ -284,8 +389,7 @@ impl Transport { fn connecting(&self, dial: &Dial) -> Result<(DialSlot<'_>, quinn::Connecting), DialError> { let slot = { let mut inner = self.shared.lock(); - let open = inner.conns.values().filter(|conn| !conn.closed).count(); - if open + inner.dialing >= MAX_CONNECTIONS { + if inner.conns.len() + inner.dialing >= MAX_CONNECTIONS { return Err(DialError::Limit); } inner.dialing += 1; @@ -348,6 +452,7 @@ impl Transport { Conn { quic: connection, streams: Vec::new(), + opening: 0, buffered, closed: false, task, @@ -359,31 +464,31 @@ impl Transport { /// Open a bidirectional stream and send its kind byte, within /// [`OPEN_TIMEOUT`]. pub(super) async fn open(&self, conn: u32, kind: u8) -> Result { - let (quic, buffered) = self.open_target(conn)?; + let (slot, quic, buffered) = self.open_target(conn)?; let opened = self - .run(OPEN_TIMEOUT, async move { quic.open_bi().await }) + .run(OPEN_TIMEOUT, async move { + quic.open_bi() + .await + .map(|(send, recv)| (ResetOnDrop(send), recv)) + }) .await; - self.opened(conn, kind, buffered, opened) + self.opened(slot, conn, kind, buffered, opened) } - fn open_target(&self, conn: u32) -> Result<(quinn::Connection, Arc), OpenError> { - let inner = self.shared.lock(); - let entry = inner.conns.get(&conn).ok_or(OpenError::Closed)?; - if entry.closed { - return Err(OpenError::Closed); - } - if entry.streams.len() >= MAX_STREAMS_PER_CONNECTION { - return Err(OpenError::Limit); - } - Ok((entry.quic.clone(), entry.buffered.clone())) + fn open_target(&self, conn: u32) -> Result<(StreamSlot, quinn::Connection, Arc), OpenError> { + let mut inner = self.shared.lock(); + let slot = StreamSlot::reserve(&self.shared, &mut inner, conn)?; + let entry = &inner.conns[&conn]; + Ok((slot, entry.quic.clone(), entry.buffered.clone())) } fn opened( &self, + slot: StreamSlot, conn: u32, kind: u8, buffered: Arc, - opened: Option>, + opened: Option>, ) -> Result { let (send, recv) = match opened { Some(Ok(pair)) => pair, @@ -397,8 +502,8 @@ impl Transport { if entry.streams.len() >= MAX_STREAMS_PER_CONNECTION { return Err(OpenError::Limit); } - // The writer subtracts every byte it flushes, so count the kind byte. - buffered.fetch_add(1, Ordering::AcqRel); + let reservation = Reservation::new(&buffered, 1).ok_or(OpenError::Limit)?; + slot.commit(&mut inner); let stream = register_stream( self.runtime().handle(), &self.shared, @@ -412,6 +517,7 @@ impl Transport { let _ = entry.tx.send(Outgoing { bytes: vec![kind], fin: false, + _reservation: reservation, }); Ok(stream) } @@ -432,10 +538,7 @@ impl Transport { .map(|conn| conn.buffered.clone()) .ok_or(SendError::Closed)?; let framed_len = message.len() + 4; - if buffered.load(Ordering::Acquire) + framed_len > MAX_BUFFERED_BYTES_PER_CONNECTION { - return Err(SendError::Limit); - } - buffered.fetch_add(framed_len, Ordering::AcqRel); + let reservation = Reservation::new(&buffered, framed_len).ok_or(SendError::Limit)?; let mut bytes = Vec::with_capacity(framed_len); bytes.extend_from_slice(&(message.len() as u32).to_le_bytes()); bytes.extend_from_slice(message); @@ -445,8 +548,16 @@ impl Transport { } entry .tx - .send(Outgoing { bytes, fin }) - .map_err(|_| SendError::Closed) + .send(Outgoing { + bytes, + fin, + _reservation: reservation, + }) + .map_err(|_| SendError::Closed)?; + if fin && entry.consumed { + inner.forget_stream(stream, false); + } + Ok(()) } /// Pop one complete message if any; report fin or reset once drained. @@ -461,14 +572,13 @@ impl Transport { .get_mut(&stream) .filter(|entry| !entry.consumed) .ok_or(Closed)?; - let mut released = 0; - if entry.inbox.front().is_some_and(|front| front.len() > max) { + if entry.inbox.front().is_some_and(|front| front.bytes.len() > max) { // The guest cannot take this message; the stream cannot progress. - released = entry.inbox.drain(..).map(|message| message.len()).sum(); + entry.inbox.clear(); entry.reset = true; entry.reader.abort(); } - let message = entry.inbox.pop_front(); + let message = entry.inbox.pop_front().map(|incoming| incoming.bytes); let drained = entry.inbox.is_empty(); let received = Received { fin: drained && entry.fin, @@ -477,11 +587,6 @@ impl Transport { }; entry.consumed = received.message.is_none() && (received.fin || received.reset); let forget = entry.consumed && (received.reset || !entry.send_open); - let conn = entry.conn; - released += received.message.as_ref().map_or(0, Vec::len); - if let Some(conn) = inner.conns.get(&conn) { - conn.buffered.fetch_sub(released, Ordering::AcqRel); - } if forget { inner.forget_stream(stream, received.reset); } @@ -566,14 +671,14 @@ fn register_stream( shared: &Shared, inner: &mut Inner, conn: u32, - send: quinn::SendStream, + send: ResetOnDrop, recv: quinn::RecvStream, buffered: Arc, ) -> u32 { let stream = inner.allocate(); let (tx, rx) = mpsc::unbounded_channel(); - let reader = handle.spawn(read_loop(shared.clone(), stream, recv, buffered.clone())); - let writer = handle.spawn(write_loop(shared.clone(), stream, send, rx, buffered)); + let reader = handle.spawn(read_loop(shared.clone(), stream, recv, buffered)); + let writer = handle.spawn(write_loop(shared.clone(), stream, send, rx)); inner.streams.insert( stream, Stream { @@ -603,50 +708,78 @@ async fn accept_loop( quic: quinn::Connection, buffered: Arc, ) { + let mut accepting = FuturesUnordered::new(); loop { - match quic.accept_bi().await { - Ok((send, mut recv)) => { - let mut kind = [0u8; 1]; - let read = tokio::time::timeout(ACCEPT_KIND_TIMEOUT, recv.read_exact(&mut kind)); - if !matches!(read.await, Ok(Ok(()))) { - // Dropping both halves resets the stream. - continue; + tokio::select! { + _ = accepting.next(), if !accepting.is_empty() => {} + incoming = quic.accept_bi() => match incoming { + Ok((send, recv)) => { + let send = ResetOnDrop(send); + let slot = { + let mut inner = shared.lock(); + if inner.events.len() >= MAX_PENDING_EVENTS { + continue; + } + match StreamSlot::reserve(&shared, &mut inner, conn) { + Ok(slot) => slot, + Err(OpenError::Closed) => return, + Err(OpenError::Limit) => continue, + } + }; + accepting.push(accept_stream( + shared.clone(), slot, conn, send, recv, buffered.clone(), + )); } - let mut inner = shared.lock(); - let Some(entry) = inner.conns.get(&conn).filter(|entry| !entry.closed) else { + Err(error) => { + tracing::debug!("JAM peer connection {conn} closed: {error}"); + let mut inner = shared.lock(); + if let Some(entry) = inner.conns.get_mut(&conn) { + entry.closed = true; + inner.push_event(latest::JamPeerTransportEvent::ConnClosed { conn }); + } return; - }; - if entry.streams.len() >= MAX_STREAMS_PER_CONNECTION { - continue; - } - let stream = register_stream( - &tokio::runtime::Handle::current(), - &shared, - &mut inner, - conn, - send, - recv, - buffered.clone(), - ); - inner.push_event(latest::JamPeerTransportEvent::Accepted { - conn, - stream, - kind: kind[0], - }); - } - Err(error) => { - tracing::debug!("JAM peer connection {conn} closed: {error}"); - let mut inner = shared.lock(); - if let Some(entry) = inner.conns.get_mut(&conn) { - entry.closed = true; - inner.push_event(latest::JamPeerTransportEvent::ConnClosed { conn }); } - return; } } } } +async fn accept_stream( + shared: Shared, + slot: StreamSlot, + conn: u32, + send: ResetOnDrop, + mut recv: quinn::RecvStream, + buffered: Arc, +) { + let mut kind = [0u8; 1]; + let read = tokio::time::timeout(ACCEPT_KIND_TIMEOUT, recv.read_exact(&mut kind)); + if !matches!(read.await, Ok(Ok(()))) { + return; + } + let mut inner = shared.lock(); + if !inner.conns.get(&conn).is_some_and(|entry| !entry.closed) + || inner.events.len() >= MAX_PENDING_EVENTS + { + return; + } + slot.commit(&mut inner); + let stream = register_stream( + &tokio::runtime::Handle::current(), + &shared, + &mut inner, + conn, + send, + recv, + buffered, + ); + inner.push_event(latest::JamPeerTransportEvent::Accepted { + conn, + stream, + kind: kind[0], + }); +} + async fn read_loop( shared: Shared, stream: u32, @@ -655,9 +788,6 @@ async fn read_loop( ) { use quinn::ReadExactError; loop { - while buffered.load(Ordering::Acquire) >= MAX_BUFFERED_BYTES_PER_CONNECTION { - tokio::time::sleep(BACKPRESSURE_POLL).await; - } let mut len = [0u8; 4]; let clean_fin = match recv.read_exact(&mut len).await { Ok(()) => None, @@ -674,15 +804,25 @@ async fn read_loop( finish_read(&shared, stream, false); return; } + // Reserve before allocating or reading the payload. Include framing + // so a peer cannot buffer an unbounded number of empty messages. + let reservation = loop { + if let Some(reservation) = Reservation::new(&buffered, len + 4) { + break reservation; + } + tokio::time::sleep(BACKPRESSURE_POLL).await; + }; let mut message = vec![0u8; len]; if recv.read_exact(&mut message).await.is_err() { finish_read(&shared, stream, false); return; } - buffered.fetch_add(len, Ordering::AcqRel); let mut inner = shared.lock(); match inner.streams.get_mut(&stream) { - Some(entry) => entry.inbox.push_back(message), + Some(entry) => entry.inbox.push_back(Incoming { + bytes: message, + _reservation: reservation, + }), None => return, } } @@ -703,28 +843,306 @@ fn finish_read(shared: &Shared, stream: u32, clean: bool) { async fn write_loop( shared: Shared, stream: u32, - mut send: quinn::SendStream, + mut send: ResetOnDrop, mut rx: mpsc::UnboundedReceiver, - buffered: Arc, ) { while let Some(outgoing) = rx.recv().await { - let written = send.write_all(&outgoing.bytes).await; - buffered.fetch_sub(outgoing.bytes.len(), Ordering::AcqRel); + let Outgoing { bytes, fin, _reservation } = outgoing; + let written = send.0.write_all(&bytes).await; + drop(bytes); + drop(_reservation); if written.is_err() { - if let Some(entry) = shared.lock().streams.get_mut(&stream) { + let mut inner = shared.lock(); + if let Some(entry) = inner.streams.get_mut(&stream) { entry.send_open = false; + if entry.consumed { + inner.forget_stream(stream, true); + } } return; } - if outgoing.fin { - let _ = send.finish(); - // Keep the handle until the peer acknowledges or the stream is - // dropped by `reset`/`close`; dropping early would not reset a - // finished stream but would forfeit the stopped notification. - let _ = send.stopped().await; + if fin { + let _ = send.0.finish(); + // Retain the reset guard until acknowledged, so reset/close can + // still abandon buffered transmission after finish. + let _ = send.0.stopped().await; return; } } - // Channel closed: the stream was reset or its connection closed. Dropping - // an unfinished SendStream resets it. + // The reset guard also aborts the send side when its queue closes. +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reservations_bound_empty_frames_and_release_queued_bytes_on_drop() { + let buffered = Arc::new(AtomicUsize::new(0)); + let held = Reservation::new(&buffered, MAX_BUFFERED_BYTES_PER_CONNECTION - 4).unwrap(); + let (tx, rx) = mpsc::unbounded_channel(); + tx.send(Outgoing { + bytes: vec![0; 4], + fin: false, + _reservation: Reservation::new(&buffered, 4).unwrap(), + }) + .ok() + .unwrap(); + assert!(Reservation::new(&buffered, 4).is_none()); + drop(rx); + assert_eq!(buffered.load(Ordering::Acquire), MAX_BUFFERED_BYTES_PER_CONNECTION - 4); + drop(held); + assert_eq!(buffered.load(Ordering::Acquire), 0); + + let mut inbox = VecDeque::new(); + inbox.push_back(Incoming { + bytes: Vec::new(), + _reservation: Reservation::new(&buffered, 4).unwrap(), + }); + assert_eq!(buffered.load(Ordering::Acquire), 4); + let message = inbox.pop_front().map(|incoming| incoming.bytes); + assert_eq!(message, Some(Vec::new())); + assert_eq!(buffered.load(Ordering::Acquire), 0); + } + + #[test] + fn simultaneous_reservations_never_exceed_the_connection_budget() { + let buffered = Arc::new(AtomicUsize::new(0)); + let barrier = std::sync::Barrier::new(16); + std::thread::scope(|scope| { + let workers: Vec<_> = (0..16) + .map(|_| scope.spawn(|| { + let reservation = Reservation::new(&buffered, MAX_MESSAGE_BYTES); + barrier.wait(); + let used = buffered.load(Ordering::Acquire); + barrier.wait(); + (reservation.is_some(), used) + })) + .collect(); + let results: Vec<_> = workers.into_iter().map(|worker| worker.join().unwrap()).collect(); + assert!(results.iter().all(|&(_, used)| used == MAX_BUFFERED_BYTES_PER_CONNECTION)); + assert_eq!(results.iter().filter(|&&(granted, _)| granted).count(), 4); + }); + assert_eq!(buffered.load(Ordering::Acquire), 0); + } + + #[tokio::test] + async fn dropping_a_driver_call_cancels_its_work_and_releases_its_reservations() { + let transport = Transport::new().unwrap(); + let buffered = Arc::new(AtomicUsize::new(0)); + let reservation = Reservation::new(&buffered, 4).unwrap(); + let (started, ready) = tokio::sync::oneshot::channel(); + let mut call = Box::pin(transport.run(Duration::from_secs(60), async move { + let _reservation = reservation; + let _ = started.send(()); + std::future::pending::<()>().await; + })); + tokio::select! { + _ = &mut call => panic!("pending work completed"), + _ = ready => {} + } + drop(call); + tokio::time::timeout(Duration::from_secs(1), async { + while buffered.load(Ordering::Acquire) != 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("dropping the caller aborts its driver task"); + } + + fn server() -> (quinn::Endpoint, Identity) { + let identity = Identity::generate().unwrap(); + let mut tls = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .with_no_client_auth() + .with_single_cert(identity.cert_chain(), identity.private_key()) + .unwrap(); + tls.alpn_protocols = vec![super::super::alpn(&[1; 32]).into_bytes()]; + let config = quinn::ServerConfig::with_crypto(Arc::new( + quinn::crypto::rustls::QuicServerConfig::try_from(tls).unwrap(), + )); + let endpoint = quinn::Endpoint::server( + config, + (std::net::Ipv4Addr::LOCALHOST, 0).into(), + ) + .unwrap(); + (endpoint, identity) + } + + async fn connect( + transport: &Transport, + server: &quinn::Endpoint, + identity: &Identity, + ) -> (u32, quinn::Connection) { + let request = Dial { + genesis: [1; 32], + ip: std::net::Ipv4Addr::LOCALHOST.to_ipv6_mapped().octets(), + port: server.local_addr().unwrap().port(), + ed25519: *identity.public(), + }; + let (client, peer) = tokio::join!( + transport.dial(&request), + async { server.accept().await.unwrap().await.unwrap() }, + ); + (client.unwrap(), peer) + } + + #[tokio::test] + async fn sending_fin_after_consuming_peer_fin_releases_the_stream_slot() { + let transport = Transport::new().unwrap(); + let (server, identity) = server(); + let (conn, peer) = connect(&transport, &server, &identity).await; + let stream = transport.open(conn, 0).await.unwrap(); + let (mut send, mut recv) = peer.accept_bi().await.unwrap(); + let mut kind = [0]; + recv.read_exact(&mut kind).await.unwrap(); + send.finish().unwrap(); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if transport.recv(stream, 1024).unwrap().fin { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + transport.send(stream, b"done", true).unwrap(); + assert!(!transport.shared.lock().streams.contains_key(&stream)); + let mut framed = [0; 8]; + recv.read_exact(&mut framed).await.unwrap(); + assert_eq!(&framed, b"\x04\0\0\0done"); + } + + #[tokio::test] + async fn remote_closed_handles_still_consume_connection_slots_until_closed() { + let transport = Transport::new().unwrap(); + let (server, identity) = server(); + let mut conns = Vec::new(); + for _ in 0..MAX_CONNECTIONS { + let (conn, peer) = connect(&transport, &server, &identity).await; + peer.close(0u32.into(), b""); + tokio::time::timeout(Duration::from_secs(1), async { + while !transport.shared.lock().conns[&conn].closed { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + conns.push(conn); + } + let request = Dial { + genesis: [1; 32], + ip: std::net::Ipv4Addr::LOCALHOST.to_ipv6_mapped().octets(), + port: server.local_addr().unwrap().port(), + ed25519: *identity.public(), + }; + assert_eq!(transport.dial(&request).await, Err(DialError::Limit)); + transport.close(conns[0]).unwrap(); + connect(&transport, &server, &identity).await; + } + + #[tokio::test] + async fn empty_messages_pause_at_quota_and_reset_releases_buffers_and_resets_peer() { + let transport = Transport::new().unwrap(); + let (server, identity) = server(); + let (conn, peer) = connect(&transport, &server, &identity).await; + let stream = transport.open(conn, 0).await.unwrap(); + let (mut send, mut recv) = peer.accept_bi().await.unwrap(); + let mut kind = [0]; + recv.read_exact(&mut kind).await.unwrap(); + let buffered = transport.shared.lock().conns[&conn].buffered.clone(); + tokio::time::timeout(Duration::from_secs(1), async { + while buffered.load(Ordering::Acquire) != 0 { + tokio::task::yield_now().await; + } + }).await.unwrap(); + let held = Reservation::new(&buffered, MAX_BUFFERED_BYTES_PER_CONNECTION - 8).unwrap(); + send.write_all(&[0; 12]).await.unwrap(); + tokio::time::timeout(Duration::from_secs(1), async { + while transport.shared.lock().streams[&stream].inbox.len() != 2 { + tokio::task::yield_now().await; + } + }).await.unwrap(); + assert_eq!(buffered.load(Ordering::Acquire), MAX_BUFFERED_BYTES_PER_CONNECTION); + assert_eq!(transport.recv(stream, 0).unwrap().message, Some(Vec::new())); + tokio::time::timeout(Duration::from_secs(1), async { + while transport.shared.lock().streams[&stream].inbox.len() != 2 { + tokio::task::yield_now().await; + } + }).await.unwrap(); + transport.reset(stream).unwrap(); + drop(held); + tokio::time::timeout(Duration::from_secs(1), async { + while buffered.load(Ordering::Acquire) != 0 { + tokio::task::yield_now().await; + } + }).await.unwrap(); + let result = tokio::time::timeout(Duration::from_secs(1), recv.read(&mut kind)) + .await + .unwrap(); + assert!(matches!(result, Err(quinn::ReadError::Reset(_)))); + } + + #[tokio::test] + async fn an_inbound_stream_is_reset_when_its_handle_cannot_be_announced() { + let transport = Transport::new().unwrap(); + let (server, identity) = server(); + let (_conn, peer) = connect(&transport, &server, &identity).await; + { + let mut inner = transport.shared.lock(); + for _ in 0..MAX_PENDING_EVENTS { + inner.push_event(latest::JamPeerTransportEvent::StreamFin { stream: 0 }); + } + } + let (mut send, mut recv) = peer.open_bi().await.unwrap(); + send.write_all(&[0]).await.unwrap(); + let mut bytes = [0]; + let result = tokio::time::timeout(Duration::from_secs(1), recv.read(&mut bytes)) + .await + .unwrap(); + assert!(matches!(result, Err(quinn::ReadError::Reset(_)))); + assert!(transport.shared.lock().streams.is_empty()); + } + + #[tokio::test] + async fn pending_streams_hold_slots_and_dropping_them_releases_credit() { + let transport = Transport::new().unwrap(); + let (server, identity) = server(); + let (conn, _peer) = connect(&transport, &server, &identity).await; + let pending: Vec<_> = (0..MAX_STREAMS_PER_CONNECTION) + .map(|_| transport.open_target(conn).unwrap()) + .collect(); + assert!(matches!(transport.open_target(conn), Err(OpenError::Limit))); + drop(pending); + assert!(transport.open_target(conn).is_ok()); + assert_eq!(transport.shared.lock().conns[&conn].opening, 0); + } + + #[tokio::test] + async fn an_inbound_stream_without_a_kind_does_not_block_later_streams() { + let transport = Transport::new().unwrap(); + let (server, identity) = server(); + let (conn, peer) = connect(&transport, &server, &identity).await; + // Sending on the higher stream id implicitly opens this lower id, + // but leaves its kind byte unavailable. + let (_silent_send, _silent_recv) = peer.open_bi().await.unwrap(); + let (mut send, _recv) = peer.open_bi().await.unwrap(); + send.write_all(&[42]).await.unwrap(); + let stream = tokio::time::timeout(Duration::from_secs(1), async { + loop { + for event in transport.events() { + if let latest::JamPeerTransportEvent::Accepted { stream, kind: 42, .. } = event { + return stream; + } + } + tokio::task::yield_now().await; + } + }).await.expect("one missing kind byte must not stall another stream"); + assert_eq!(transport.shared.lock().streams[&stream].conn, conn); + } } diff --git a/rust/crates/truapi/src/jam_peer_transport/session.rs b/rust/crates/truapi/src/jam_peer_transport/session.rs index 0499608814..79404ddc4a 100644 --- a/rust/crates/truapi/src/jam_peer_transport/session.rs +++ b/rust/crates/truapi/src/jam_peer_transport/session.rs @@ -6,6 +6,9 @@ //! answer, so concurrent dials wait for one prompt and a refusal stays //! `NotGranted` without asking again. The check runs on the runtime spawner, //! so its answer is kept even when every dial waiting on it has given up. +//! Pending dials reserve from the eight-connection budget before permission +//! is awaited. At most eight distinct genesis decisions are kept, including +//! pending and refused checks; a ninth is `Limit`, with no eviction or prompt. //! //! A dial answers within [`DIAL_DEADLINE`], prompt included: one still waiting //! then answers `Unreachable`, a cancelled one `Cancelled`, and whatever it @@ -17,7 +20,7 @@ use core::time::Duration; use std::collections::HashMap; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use futures::{FutureExt, pin_mut}; use parking_lot::Mutex; @@ -49,8 +52,41 @@ pub(crate) struct JamPeerSession { transport: Mutex>>, /// Permission answers for this connection, by genesis. decisions: Mutex>>>, + /// Dials still awaiting permission or a handshake. + pending_dials: AtomicUsize, dial_deadline: Duration, revoked: AtomicBool, + revoked_signal: truapi::CancellationToken, +} + +/// A pending dial reserves capacity before authorization and releases it on +/// every completion path. Successful connections are then counted by QUIC. +struct DialAdmission<'a> { + slots: &'a AtomicUsize, +} + +impl<'a> DialAdmission<'a> { + fn reserve( + slots: &'a AtomicUsize, + transport: Option<&Transport>, + ) -> Result> { + let admitted = match transport { + Some(transport) => transport.reserve_pending_dial(slots), + None => slots.fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| { + (used < quic::MAX_CONNECTIONS).then_some(used + 1) + }).is_ok(), + }; + if !admitted { + return Err(dial_error(latest::HostJamPeerTransportDialError::Limit)); + } + Ok(Self { slots }) + } +} + +impl Drop for DialAdmission<'_> { + fn drop(&mut self) { + self.slots.fetch_sub(1, Ordering::AcqRel); + } } fn dial_error( @@ -65,8 +101,10 @@ impl JamPeerSession { Self { transport: Mutex::new(None), decisions: Mutex::new(HashMap::new()), + pending_dials: AtomicUsize::new(0), dial_deadline: DIAL_DEADLINE, revoked: AtomicBool::new(false), + revoked_signal: truapi::CancellationToken::default(), } } @@ -78,6 +116,7 @@ impl JamPeerSession { self.revoked.store(true, Ordering::Release); transport.take() }; + self.revoked_signal.cancel(); if let Some(transport) = transport { transport.shutdown(); } @@ -125,6 +164,9 @@ impl JamPeerSession { match decisions.get(&genesis) { Some(decision) => (decision.clone(), None), None => { + if decisions.len() >= quic::MAX_CONNECTIONS { + return Err(dial_error(latest::HostJamPeerTransportDialError::Limit)); + } let (answer, decision) = watch::channel(None); decisions.insert(genesis, decision.clone()); (decision, Some(answer)) @@ -132,9 +174,15 @@ impl JamPeerSession { } }; if let Some(answer) = first { - let check = authorize(); + let check = authorize().fuse(); + let revoked = self.revoked_signal.cancelled().fuse(); spawner(Box::pin(async move { - let _ = answer.send(Some(check.await)); + pin_mut!(check, revoked); + let result = futures::select_biased! { + _ = revoked => Err(CallError::Denied), + result = check => result, + }; + let _ = answer.send(Some(result)); })); } match decision.wait_for(Option::is_some).await { @@ -158,6 +206,12 @@ impl JamPeerSession { where F: Future + Send + 'static, { + let _admission = { + // Serialize admission with endpoint creation and revocation. + let transport = self.transport.lock(); + self.live()?; + DialAdmission::reserve(&self.pending_dials, transport.as_deref())? + }; self.permitted(request.genesis, authorize, spawner).await?; let transport = self.endpoint()?; // Native hosts speak JAMNP-S QUIC; the P-256 id is for WebTransport hosts. @@ -210,10 +264,12 @@ impl JamPeerSession { let granted = self.dial_granted(request, authorize, spawner).fuse(); let cancelled = cx.cancel().cancelled().fuse(); let deadline = futures_timer::Delay::new(self.dial_deadline).fuse(); - pin_mut!(granted, cancelled, deadline); + let revoked = self.revoked_signal.cancelled().fuse(); + pin_mut!(granted, cancelled, deadline, revoked); futures::select_biased! { - reply = granted => reply, + _ = revoked => Err(CallError::Denied), _ = cancelled => Err(CallError::Cancelled), + reply = granted => reply, () = deadline => Err(dial_error(latest::HostJamPeerTransportDialError::Unreachable)), } } @@ -221,6 +277,7 @@ impl JamPeerSession { /// Open a stream on a connection a granted dial opened. pub(crate) async fn open( &self, + cx: &CallContext, request: wire::HostJamPeerTransportOpenRequest, ) -> Result< wire::HostJamPeerTransportOpenResponse, @@ -234,18 +291,23 @@ impl JamPeerSession { )) }; let transport = self.existing().ok_or_else(closed)?; - let stream = - transport - .open(request.conn, request.kind) - .await - .map_err(|error| match error { - quic::OpenError::Closed => closed(), - quic::OpenError::Limit => { - CallError::Domain(wire::HostJamPeerTransportOpenError::V1( - latest::HostJamPeerTransportOpenError::Limit, - )) - } - })?; + let opened = transport.open(request.conn, request.kind).fuse(); + let cancelled = cx.cancel().cancelled().fuse(); + let revoked = self.revoked_signal.cancelled().fuse(); + pin_mut!(opened, cancelled, revoked); + let result = futures::select_biased! { + _ = revoked => return Err(CallError::Denied), + _ = cancelled => return Err(CallError::Cancelled), + result = opened => result, + }; + let stream = result.map_err(|error| match error { + quic::OpenError::Closed => closed(), + quic::OpenError::Limit => { + CallError::Domain(wire::HostJamPeerTransportOpenError::V1( + latest::HostJamPeerTransportOpenError::Limit, + )) + } + })?; if self.revoked.load(Ordering::Acquire) { let _ = transport.reset(stream); return Err(CallError::Denied); @@ -370,3 +432,9 @@ impl JamPeerSession { #[cfg(test)] mod tests; + +impl Drop for JamPeerSession { + fn drop(&mut self) { + self.revoke(); + } +} diff --git a/rust/crates/truapi/src/jam_peer_transport/session/tests.rs b/rust/crates/truapi/src/jam_peer_transport/session/tests.rs index f4065427cc..ed2e7afeb8 100644 --- a/rust/crates/truapi/src/jam_peer_transport/session/tests.rs +++ b/rust/crates/truapi/src/jam_peer_transport/session/tests.rs @@ -48,10 +48,9 @@ fn prompt( } fn session_with_deadline(dial_deadline: Duration) -> JamPeerSession { - JamPeerSession { - dial_deadline, - ..JamPeerSession::new() - } + let mut session = JamPeerSession::new(); + session.dial_deadline = dial_deadline; + session } fn dial_request( @@ -91,6 +90,14 @@ fn silent_port() -> (std::net::UdpSocket, u16) { /// A JAMNP-S peer on loopback: presents a certificate for `identity`, then /// answers the first message of the first stream with `reply` and finishes. fn peer(identity: &Identity, reply: &'static [u8]) -> (quinn::Endpoint, u16) { + peer_with_stream_credit(identity, reply, 100) +} + +fn peer_with_stream_credit( + identity: &Identity, + reply: &'static [u8], + credit: u32, +) -> (quinn::Endpoint, u16) { let provider = Arc::new(rustls::crypto::ring::default_provider()); let mut tls = rustls::ServerConfig::builder_with_provider(provider) .with_protocol_versions(&[&rustls::version::TLS13]) @@ -100,8 +107,12 @@ fn peer(identity: &Identity, reply: &'static [u8]) -> (quinn::Endpoint, u16) { .unwrap(); tls.alpn_protocols = vec![super::super::alpn(&GENESIS).into_bytes()]; let crypto = quinn::crypto::rustls::QuicServerConfig::try_from(tls).unwrap(); + let mut config = quinn::ServerConfig::with_crypto(Arc::new(crypto)); + let mut transport = quinn::TransportConfig::default(); + transport.max_concurrent_bidi_streams(credit.into()); + config.transport_config(Arc::new(transport)); let endpoint = quinn::Endpoint::server( - quinn::ServerConfig::with_crypto(Arc::new(crypto)), + config, (Ipv4Addr::LOCALHOST, 0).into(), ) .unwrap(); @@ -109,7 +120,10 @@ fn peer(identity: &Identity, reply: &'static [u8]) -> (quinn::Endpoint, u16) { let server = endpoint.clone(); tokio::spawn(async move { let connection = server.accept().await.unwrap().await.unwrap(); - let (mut send, mut recv) = connection.accept_bi().await.unwrap(); + // Admission/lifecycle tests intentionally close without opening a stream. + let Ok((mut send, mut recv)) = connection.accept_bi().await else { + return; + }; let mut kind = [0u8; 1]; recv.read_exact(&mut kind).await.unwrap(); let mut length = [0u8; 4]; @@ -156,7 +170,7 @@ fn a_granted_dial_frames_messages_outside_tokio_and_revoke_denies_everything() { let wire::HostJamPeerTransportOpenResponse::V1(latest::HostJamPeerTransportOpenResponse { stream, }) = session - .open(wire::HostJamPeerTransportOpenRequest::V1( + .open(&cx(), wire::HostJamPeerTransportOpenRequest::V1( latest::HostJamPeerTransportOpenRequest { conn, kind: 0 }, )) .await @@ -351,6 +365,7 @@ async fn a_prompt_outlasting_the_deadline_is_unreachable_and_remembered() { dial_failure(late), Some(latest::HostJamPeerTransportDialError::Unreachable) ); + assert_eq!(session.pending_dials.load(Ordering::Acquire), 0); // The user answers after the guest stopped waiting: nothing was opened. answer.send(true).unwrap(); tokio::time::sleep(Duration::from_millis(20)).await; @@ -445,3 +460,221 @@ async fn a_handshake_outlasting_the_deadline_frees_its_slot() { ); } } + +#[tokio::test] +async fn revoking_a_session_interrupts_a_pending_permission_prompt() { + let session = JamPeerSession::new(); + let (_silent, port) = silent_port(); + let (started, ready) = tokio::sync::oneshot::channel(); + let (held, dropped) = tokio::sync::oneshot::channel::<()>(); + let call_context = cx(); + let spawner = test_spawner(); + let mut dial = Box::pin(session.dial( + &call_context, + dial_request(GENESIS, port, [7; 32]), + || async move { + let _held = held; + let _ = started.send(()); + std::future::pending::().await + }, + &spawner, + )); + tokio::select! { + _ = &mut dial => panic!("an unanswered prompt completed"), + _ = ready => {} + } + session.revoke(); + let result = tokio::time::timeout(Duration::from_millis(100), dial) + .await + .expect("revocation must not wait for the dial deadline"); + assert!(matches!(result, Err(CallError::Denied))); + assert!(session.existing().is_none()); + assert!(tokio::time::timeout(Duration::from_secs(1), dropped).await.unwrap().is_err()); +} + +#[tokio::test] +async fn a_pre_cancelled_dial_does_not_prompt_or_bind() { + let session = JamPeerSession::new(); + let call_context = cx(); + call_context.cancel().cancel(); + let result = session.dial( + &call_context, + dial_request(GENESIS, 1, [7; 32]), + || async { panic!("a cancelled dial must not start authorization") }, + &test_spawner(), + ).await; + assert!(matches!(result, Err(CallError::Cancelled))); + assert!(session.existing().is_none()); +} + +#[tokio::test] +async fn cancelling_open_does_not_wait_for_the_peers_stream_credit() { + let identity = Identity::generate().unwrap(); + let (_peer, port) = peer_with_stream_credit(&identity, b"unused", 0); + let session = JamPeerSession::new(); + let wire::HostJamPeerTransportDialResponse::V1(response) = session.dial( + &cx(), + dial_request(GENESIS, port, *identity.public()), + || async { Ok(()) }, + &test_spawner(), + ).await.unwrap(); + let call_context = cx(); + let request = wire::HostJamPeerTransportOpenRequest::V1( + latest::HostJamPeerTransportOpenRequest { conn: response.conn, kind: 0 }, + ); + let mut open = Box::pin(session.open(&call_context, request)); + assert!(futures::poll!(&mut open).is_pending()); + call_context.cancel().cancel(); + let result = tokio::time::timeout(Duration::from_millis(100), open).await.unwrap(); + assert!(matches!(result, Err(CallError::Cancelled))); +} + +#[tokio::test] +async fn permission_waits_are_bounded_before_prompting_and_cancelled_slots_are_reusable() { + let session = JamPeerSession::new(); + let asked = Arc::new(AtomicUsize::new(0)); + let (_answer, decision) = watch::channel(false); + let spawner = test_spawner(); + let contexts: Vec<_> = (0..quic::MAX_CONNECTIONS).map(|_| cx()).collect(); + let mut dials: Vec<_> = contexts.iter().enumerate().map(|(index, context)| { + Box::pin(session.dial( + context, + dial_request([index as u8; 32], 1, [7; 32]), + || prompt(&asked, &decision), + &spawner, + )) + }).collect(); + for dial in &mut dials { + assert!(futures::poll!(dial.as_mut()).is_pending()); + } + assert_eq!(asked.load(Ordering::SeqCst), quic::MAX_CONNECTIONS); + assert_eq!(session.pending_dials.load(Ordering::Acquire), quic::MAX_CONNECTIONS); + assert!(session.existing().is_none()); + + let ninth = session.dial( + &cx(), + dial_request([0; 32], 1, [7; 32]), + || async { panic!("capacity must be checked before authorization") }, + &spawner, + ).await.unwrap_err(); + assert_eq!(dial_failure(ninth), Some(latest::HostJamPeerTransportDialError::Limit)); + contexts[0].cancel().cancel(); + assert!(matches!(dials[0].as_mut().await, Err(CallError::Cancelled))); + assert_eq!(session.pending_dials.load(Ordering::Acquire), quic::MAX_CONNECTIONS - 1); + + let retry_context = cx(); + let mut retry = Box::pin(session.dial( + &retry_context, + dial_request([0; 32], 1, [7; 32]), + || async { panic!("a cached pending decision must not prompt again") }, + &spawner, + )); + assert!(futures::poll!(&mut retry).is_pending()); + assert_eq!(session.pending_dials.load(Ordering::Acquire), quic::MAX_CONNECTIONS); + drop(retry); + drop(dials); + assert_eq!(session.pending_dials.load(Ordering::Acquire), 0); + + // Pending decisions remain bounded even after every caller has left. + let ninth = session.dial( + &cx(), + dial_request([9; 32], 1, [7; 32]), + || async { panic!("the decision cache must not evict a pending check") }, + &spawner, + ).await.unwrap_err(); + assert_eq!(dial_failure(ninth), Some(latest::HostJamPeerTransportDialError::Limit)); + assert_eq!(session.pending_dials.load(Ordering::Acquire), 0); + assert_eq!(session.decisions.lock().len(), quic::MAX_CONNECTIONS); + session.revoke(); +} + +#[tokio::test] +async fn denied_genesis_decisions_are_retained_and_the_ninth_is_limited() { + let session = JamPeerSession::new(); + let asked = AtomicUsize::new(0); + let spawner = test_spawner(); + for index in 0..quic::MAX_CONNECTIONS { + let result = session.dial( + &cx(), + dial_request([index as u8; 32], 1, [7; 32]), + || { + asked.fetch_add(1, Ordering::SeqCst); + async { not_granted() } + }, + &spawner, + ).await.unwrap_err(); + assert_eq!(dial_failure(result), Some(latest::HostJamPeerTransportDialError::NotGranted)); + } + for (genesis, expected) in [ + ([9; 32], latest::HostJamPeerTransportDialError::Limit), + ([0; 32], latest::HostJamPeerTransportDialError::NotGranted), + ] { + let result = session.dial( + &cx(), + dial_request(genesis, 1, [7; 32]), + || async { panic!("denied decisions must not be evicted or re-prompted") }, + &spawner, + ).await.unwrap_err(); + assert_eq!(dial_failure(result), Some(expected)); + } + assert_eq!(asked.load(Ordering::SeqCst), quic::MAX_CONNECTIONS); + assert_eq!(session.decisions.lock().len(), quic::MAX_CONNECTIONS); + assert_eq!(session.pending_dials.load(Ordering::Acquire), 0); + assert!(session.existing().is_none()); +} + +#[tokio::test] +async fn live_connections_and_permission_waits_share_capacity_and_close_releases_it() { + let session = JamPeerSession::new(); + let identity = Identity::generate().unwrap(); + let spawner = test_spawner(); + let mut peers = Vec::new(); + let mut conns = Vec::new(); + for _ in 0..quic::MAX_CONNECTIONS { + let (peer, port) = peer(&identity, b"unused"); + peers.push(peer); + let wire::HostJamPeerTransportDialResponse::V1(response) = session.dial( + &cx(), + dial_request(GENESIS, port, *identity.public()), + || async { Ok(()) }, + &spawner, + ).await.unwrap(); + conns.push(response.conn); + } + assert_eq!(session.pending_dials.load(Ordering::Acquire), 0); + let full = session.dial( + &cx(), + dial_request([9; 32], 1, [7; 32]), + || async { panic!("live handles must exclude new permission waits") }, + &spawner, + ).await.unwrap_err(); + assert_eq!(dial_failure(full), Some(latest::HostJamPeerTransportDialError::Limit)); + assert_eq!(session.decisions.lock().len(), 1); + + session.close(wire::HostJamPeerTransportCloseRequest::V1( + latest::HostJamPeerTransportCloseRequest { conn: conns[0] }, + )).unwrap(); + let (_answer, decision) = watch::channel(false); + let asked = Arc::new(AtomicUsize::new(0)); + let context = cx(); + let mut pending = Box::pin(session.dial( + &context, + dial_request([9; 32], 1, [7; 32]), + || prompt(&asked, &decision), + &spawner, + )); + assert!(futures::poll!(&mut pending).is_pending()); + assert_eq!(asked.load(Ordering::SeqCst), 1); + assert_eq!(session.pending_dials.load(Ordering::Acquire), 1); + let full = session.dial( + &cx(), + dial_request([10; 32], 1, [7; 32]), + || async { panic!("live plus pending must share the eight slots") }, + &spawner, + ).await.unwrap_err(); + assert_eq!(dial_failure(full), Some(latest::HostJamPeerTransportDialError::Limit)); + session.revoke(); + assert!(matches!(pending.await, Err(CallError::Denied))); + assert_eq!(session.pending_dials.load(Ordering::Acquire), 0); + drop(peers); +} diff --git a/rust/crates/truapi/src/runtime/capabilities/jam_peer_transport.rs b/rust/crates/truapi/src/runtime/capabilities/jam_peer_transport.rs index 356747c909..f2dad45294 100644 --- a/rust/crates/truapi/src/runtime/capabilities/jam_peer_transport.rs +++ b/rust/crates/truapi/src/runtime/capabilities/jam_peer_transport.rs @@ -56,11 +56,11 @@ mod native { #[instrument(skip_all, fields(runtime.method = "jam_peer_transport.open"))] async fn open( &self, - _cx: &CallContext, + cx: &CallContext, request: HostJamPeerTransportOpenRequest, ) -> Result> { - self.jam_peers.open(request).await + self.jam_peers.open(cx, request).await } #[instrument(skip_all, fields(runtime.method = "jam_peer_transport.send"))] diff --git a/rust/crates/truapi/src/v01/jam_peer_transport.rs b/rust/crates/truapi/src/v01/jam_peer_transport.rs index e37730e82e..2132e32862 100644 --- a/rust/crates/truapi/src/v01/jam_peer_transport.rs +++ b/rust/crates/truapi/src/v01/jam_peer_transport.rs @@ -19,7 +19,8 @@ pub enum HostJamPeerTransportDialError { /// The peer refused the connection or presented a certificate that does /// not match the requested identity. Refused, - /// The connection cap for this execution is exhausted. + /// The execution's connection budget (including pending dials), or its + /// eight distinct genesis decisions, is exhausted. Limit, /// The endpoint could not be reached. Unreachable, @@ -28,8 +29,9 @@ pub enum HostJamPeerTransportDialError { /// Dial one JAM peer over JAMNP-S (QUIC) or WebTransport. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostJamPeerTransportDialRequest { - /// Genesis header hash; the host derives the ALPN from it and requires a - /// `RemotePermission::JamPeers` grant for it. + /// Genesis header hash authorizing this dial. Native QUIC derives its + /// ALPN from the first four bytes; WebTransport negotiates HTTP/3. + /// Neither transport authenticates the peer's chain membership. pub genesis: [u8; 32], /// Peer IP address, IPv6 or v4-mapped IPv6. pub ip: [u8; 16], diff --git a/rust/crates/truapi/src/v01/permissions.rs b/rust/crates/truapi/src/v01/permissions.rs index 6769cbaf47..2b28c64923 100644 --- a/rust/crates/truapi/src/v01/permissions.rs +++ b/rust/crates/truapi/src/v01/permissions.rs @@ -90,12 +90,14 @@ pub enum RemotePermission { /// Submitting statements on behalf of the user via `remote_statement_store_submit`. #[display("submit statements")] StatementSubmit, - /// Read-only peer access over JAMNP-S QUIC/WebTransport to the validators - /// of one JAM chain, through the `JamPeerTransport` service. + /// Peer access over JAMNP-S QUIC/WebTransport, authorized for the full + /// genesis hash through the `JamPeerTransport` service. /// - /// The app names the endpoints it dials; the grant covers only peers of - /// `genesis`. Every byte received is untrusted, and the grant carries no - /// account, signing or submission authority. + /// The app names endpoints and pinned keys. Native QUIC negotiates the + /// genesis-derived ALPN; WebTransport negotiates HTTP/3. Neither proves + /// chain membership. The guest must verify chain data itself. The grant + /// carries no host account, signing or submission authority and does not + /// restrict which framed protocol messages the guest sends. #[display( "connections to JAM network 0x{:02x}{:02x}{:02x}{:02x}…", genesis[0], @@ -104,7 +106,7 @@ pub enum RemotePermission { genesis[3] )] JamPeers { - /// Genesis header hash of the JAM chain whose peers may be dialed. + /// Genesis header hash under which peer access is authorized. genesis: [u8; 32], }, } From 967820d4c9e06890709371b33e0aa3e0bf465561 Mon Sep 17 00:00:00 2001 From: w Date: Thu, 8 Oct 2026 21:42:17 -0400 Subject: [PATCH 29/36] refactor(ios): separate scoped remote permission mapping --- .../ProductPermissionRepository.swift | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/hosts/ios/polkadot-app/Modules/Products/Permissions/Repository/ProductPermissionRepository.swift b/hosts/ios/polkadot-app/Modules/Products/Permissions/Repository/ProductPermissionRepository.swift index cc21098cc9..50c7e0fba2 100644 --- a/hosts/ios/polkadot-app/Modules/Products/Permissions/Repository/ProductPermissionRepository.swift +++ b/hosts/ios/polkadot-app/Modules/Products/Permissions/Repository/ProductPermissionRepository.swift @@ -370,17 +370,28 @@ extension ProductPermission { } func authorizationRequest() throws -> PermissionAuthorizationRequest? { - switch try canonicalPermission() { + let permission = try canonicalPermission() + switch permission { case let .deviceCapability(capability): return .device(capability.authorizationRequest) - case let .networkAccess(domain): - return .remote(.init(permission: .remote(domains: [domain]))) - case let .networkAccessBundle(domains): - return .remote(.init(permission: .remote(domains: domains))) case let .accountAccess(target): return .accountAccess(targetProductId: target) case .userIdentityAccess: return .identityDisclosure + case .balanceAccess: + return nil + case .networkAccess, .networkAccessBundle, .webRtcAccess, + .chainSubmitAccess, .preimageSubmitAccess, .statementSubmitAccess, .jamPeersAccess: + return try permission.remoteAuthorizationRequest() + } + } + + private func remoteAuthorizationRequest() throws -> PermissionAuthorizationRequest { + switch self { + case let .networkAccess(domain): + return .remote(.init(permission: .remote(domains: [domain]))) + case let .networkAccessBundle(domains): + return .remote(.init(permission: .remote(domains: domains))) case .webRtcAccess: return .remote(.init(permission: .webRtc)) case .chainSubmitAccess: @@ -395,8 +406,8 @@ extension ProductPermission { throw ProductPermissionMappingError.unsupported(typeName, genesis) } return .remote(.init(permission: .jamPeers(genesis: bytes))) - case .balanceAccess: - return nil + default: + preconditionFailure("Expected a remote permission") } } From 65c7f691184d477b232f2f5042593b2bf5d1df84 Mon Sep 17 00:00:00 2001 From: w Date: Thu, 8 Oct 2026 21:53:03 -0400 Subject: [PATCH 30/36] fix(jam): use supported atomic quota updates --- .changeset/pvm-jam-peer-transport.md | 1 + rust/crates/truapi/src/jam_peer_transport/quic.rs | 10 ++++++---- rust/crates/truapi/src/jam_peer_transport/session.rs | 8 +++++--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.changeset/pvm-jam-peer-transport.md b/.changeset/pvm-jam-peer-transport.md index ff3406cb28..30f519d7a7 100644 --- a/.changeset/pvm-jam-peer-transport.md +++ b/.changeset/pvm-jam-peer-transport.md @@ -39,3 +39,4 @@ prompt, including batched requests, and in permission details. Short summary tit JAM consent uses the shared canonical product permission authority, including revision fences and revocation of execution-local grants, while preserving the account-neutral product/genesis scope. +Quota reservations use the current atomic update API so warning-denied source installs remain supported. diff --git a/rust/crates/truapi/src/jam_peer_transport/quic.rs b/rust/crates/truapi/src/jam_peer_transport/quic.rs index 5d8fb542b0..ccdd2bf8b7 100644 --- a/rust/crates/truapi/src/jam_peer_transport/quic.rs +++ b/rust/crates/truapi/src/jam_peer_transport/quic.rs @@ -114,7 +114,7 @@ struct Reservation { impl Reservation { fn new(buffered: &Arc, bytes: usize) -> Option { buffered - .fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| { + .try_update(Ordering::AcqRel, Ordering::Acquire, |used| { used.checked_add(bytes) .filter(|&total| total <= MAX_BUFFERED_BYTES_PER_CONNECTION) }) @@ -374,9 +374,11 @@ impl Transport { /// cannot fall between the connection and pending-count snapshots. pub(super) fn reserve_pending_dial(&self, pending: &AtomicUsize) -> bool { let inner = self.shared.lock(); - pending.fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| { - (inner.conns.len() + used < MAX_CONNECTIONS).then_some(used + 1) - }).is_ok() + pending + .try_update(Ordering::AcqRel, Ordering::Acquire, |used| { + (inner.conns.len() + used < MAX_CONNECTIONS).then_some(used + 1) + }) + .is_ok() } /// Connect to one peer, requiring its certificate to carry `ed25519`, diff --git a/rust/crates/truapi/src/jam_peer_transport/session.rs b/rust/crates/truapi/src/jam_peer_transport/session.rs index 79404ddc4a..3375f8cb88 100644 --- a/rust/crates/truapi/src/jam_peer_transport/session.rs +++ b/rust/crates/truapi/src/jam_peer_transport/session.rs @@ -72,9 +72,11 @@ impl<'a> DialAdmission<'a> { ) -> Result> { let admitted = match transport { Some(transport) => transport.reserve_pending_dial(slots), - None => slots.fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| { - (used < quic::MAX_CONNECTIONS).then_some(used + 1) - }).is_ok(), + None => slots + .try_update(Ordering::AcqRel, Ordering::Acquire, |used| { + (used < quic::MAX_CONNECTIONS).then_some(used + 1) + }) + .is_ok(), }; if !admitted { return Err(dial_error(latest::HostJamPeerTransportDialError::Limit)); From 200ebc444cd4136943a1098f73e116469160d60e Mon Sep 17 00:00:00 2001 From: w Date: Thu, 8 Oct 2026 22:23:55 -0400 Subject: [PATCH 31/36] fix(jam): verify canonical grants with the repository mock --- .../domain/permissions/RealProductPermissionGuardTest.kt | 4 ++-- rust/crates/truapi/src/api.rs | 4 ++-- rust/crates/truapi/src/v01.rs | 4 ++-- rust/crates/truapi/src/versioned.rs | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/RealProductPermissionGuardTest.kt b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/RealProductPermissionGuardTest.kt index 86f81a6e6d..b1fb1fb24e 100644 --- a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/RealProductPermissionGuardTest.kt +++ b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/RealProductPermissionGuardTest.kt @@ -112,8 +112,8 @@ class RealProductPermissionGuardTest { assertTrue(guard.requestPermissionsBatched(productId, listOf(first, second))) verify(requester).promptBatched(productId, listOf(first, second)) - verify(repository).grant(productId, first) - verify(repository).grant(productId, second) + coVerify { repository.grant(productId, first) } + coVerify { repository.grant(productId, second) } assertEquals(first, ProductPermission.fromLocal(first.typeName, first.key)) assertEquals(second, ProductPermission.fromLocal(second.typeName, second.key)) } diff --git a/rust/crates/truapi/src/api.rs b/rust/crates/truapi/src/api.rs index dad6855742..b144d70628 100644 --- a/rust/crates/truapi/src/api.rs +++ b/rust/crates/truapi/src/api.rs @@ -6,9 +6,9 @@ pub mod chat; pub mod coin_payment; pub mod contacts; pub mod entropy; -pub mod jam_peer_transport; pub mod expanded_card; pub mod game; +pub mod jam_peer_transport; pub mod local_storage; pub mod locale; pub mod notifications; @@ -31,9 +31,9 @@ pub use chat::Chat; pub use coin_payment::CoinPayment; pub use contacts::Contacts; pub use entropy::Entropy; -pub use jam_peer_transport::JamPeerTransport; pub use expanded_card::ExpandedCard; pub use game::Game; +pub use jam_peer_transport::JamPeerTransport; pub use local_storage::LocalStorage; pub use locale::Locale; pub use notifications::Notifications; diff --git a/rust/crates/truapi/src/v01.rs b/rust/crates/truapi/src/v01.rs index 156eabd7d4..4f688d519e 100644 --- a/rust/crates/truapi/src/v01.rs +++ b/rust/crates/truapi/src/v01.rs @@ -7,9 +7,9 @@ mod coin_payment; mod common; mod contacts; mod entropy; -mod jam_peer_transport; mod expanded_card; mod game; +mod jam_peer_transport; mod local_storage; mod locale; mod notifications; @@ -34,9 +34,9 @@ pub use coin_payment::*; pub use common::*; pub use contacts::*; pub use entropy::*; -pub use jam_peer_transport::*; pub use expanded_card::*; pub use game::*; +pub use jam_peer_transport::*; pub use local_storage::*; pub use locale::*; pub use notifications::*; diff --git a/rust/crates/truapi/src/versioned.rs b/rust/crates/truapi/src/versioned.rs index 92c2d4b00b..d6e658e941 100644 --- a/rust/crates/truapi/src/versioned.rs +++ b/rust/crates/truapi/src/versioned.rs @@ -40,9 +40,9 @@ pub mod chat; pub mod coin_payment; pub mod contacts; pub mod entropy; -pub mod jam_peer_transport; pub mod expanded_card; pub mod game; +pub mod jam_peer_transport; pub mod local_storage; pub mod locale; pub mod notifications; From 45af99e93e22bbfb3e4c38384f93f400ed2406fa Mon Sep 17 00:00:00 2001 From: w Date: Thu, 8 Oct 2026 22:56:50 -0400 Subject: [PATCH 32/36] fix(jam): retain stream-slot release with current lint rules --- rust/crates/truapi/src/jam_peer_transport/quic.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/rust/crates/truapi/src/jam_peer_transport/quic.rs b/rust/crates/truapi/src/jam_peer_transport/quic.rs index ccdd2bf8b7..d9901aa8e4 100644 --- a/rust/crates/truapi/src/jam_peer_transport/quic.rs +++ b/rust/crates/truapi/src/jam_peer_transport/quic.rs @@ -256,10 +256,8 @@ impl StreamSlot { impl Drop for StreamSlot { fn drop(&mut self) { - if self.armed { - if let Some(entry) = self.shared.lock().conns.get_mut(&self.conn) { - entry.opening -= 1; - } + if self.armed && let Some(entry) = self.shared.lock().conns.get_mut(&self.conn) { + entry.opening -= 1; } } } From e48777643c52278769a6bac724c7f6577cb87197 Mon Sep 17 00:00:00 2001 From: w Date: Thu, 8 Oct 2026 23:04:10 -0400 Subject: [PATCH 33/36] fix(receiving): collapse the idempotent registration guard --- rust/crates/truapi/src/runtime/receiving.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/rust/crates/truapi/src/runtime/receiving.rs b/rust/crates/truapi/src/runtime/receiving.rs index fa76743e4f..ac817efb32 100644 --- a/rust/crates/truapi/src/runtime/receiving.rs +++ b/rust/crates/truapi/src/runtime/receiving.rs @@ -243,11 +243,10 @@ impl ReceivingService { validate_watches(&watches, &authority, timestamp)?; // Startup may republish the same policy before a pending click is delivered. // Only a real policy change should fence that click or restart relay sync. - if let Some(record) = find(&ledger, &authority) { - if current(record, &authority) && record.consent && record.enabled == !watches.is_empty() - && record.watches.iter().map(|watch| &watch.policy).eq(watches.iter()) { - return Ok(status(&fresh, Some(record))); - } + if let Some(record) = find(&ledger, &authority) + && current(record, &authority) && record.consent && record.enabled == !watches.is_empty() + && record.watches.iter().map(|watch| &watch.policy).eq(watches.iter()) { + return Ok(status(&fresh, Some(record))); } let position = ledger.records.iter().position(|r| same_scope(&r.authority, &authority)); if position.is_none() && ledger.records.len() >= MAX_REGISTRATIONS { return Err(Error::Capacity); } From c5f442afd713e0d3a1520dc37b039ea7052ca020 Mon Sep 17 00:00:00 2001 From: w Date: Thu, 8 Oct 2026 23:25:44 -0400 Subject: [PATCH 34/36] fix(ios): share JAM permission prompt and settings metadata --- .../ProductPermissionPromptViewFactory.swift | 176 +++--------------- .../AppPermissionsViewModelFactory.swift | 126 +++++++------ 2 files changed, 94 insertions(+), 208 deletions(-) diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift b/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift index dbca053855..c608c07e44 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift @@ -72,94 +72,41 @@ private extension ProductPermissionPromptViewFactory { productId: String, permission: ProductPermission ) -> PromptContent { + let title: String + let body: String switch permission { case let .deviceCapability(capability): - PromptContent( - title: String( - localized: .Products.permissionTitleDeviceCapability( - productId: productId, - capability: capabilityDisplayName(capability) - ) - ), - body: capabilityDescription(capability), - icon: iconForCapability(capability) - ) - case let .networkAccess(domain): - PromptContent( - title: String( - localized: .Products.permissionTitleNetworkAccess( - productId: productId - ) - ), - body: String( - localized: .Products.permissionBodyNetworkAccess(domain: domain) - ), - icon: makeIcon(systemName: "globe") - ) - case let .networkAccessBundle(domains): - makeSingleContent(productId: productId, permission: .networkAccess(domain: domains.joined(separator: ", "))) - case let .accountAccess(targetProductId): - PromptContent( - title: String( - localized: .Products.permissionTitleAccountAccess( - productId: productId - ) - ), - body: String( - localized: .Products.permissionBodyAccountAccess( - targetProductId: targetProductId - ) - ), - icon: makeIcon(systemName: "person.crop.circle") - ) - case .balanceAccess: - PromptContent( - title: String(localized: .Products.permissionTitleRemote(productId: productId)), - body: String(localized: .Products.permissionBodyBalanceAccess), - icon: makeIcon(systemName: "dollarsign.circle.fill") - ) - case .webRtcAccess: - PromptContent( - title: String(localized: .Products.permissionTitleRemote(productId: productId)), - body: String(localized: .Products.permissionBodyWebRtc), - icon: makeIcon(systemName: "video.fill") - ) - case .chainSubmitAccess: - PromptContent( - title: String(localized: .Products.permissionTitleRemote(productId: productId)), - body: String(localized: .Products.permissionBodyChainSubmit), - icon: makeIcon(systemName: "link") - ) - case .preimageSubmitAccess: - PromptContent( - title: String(localized: .Products.permissionTitleRemote(productId: productId)), - body: String(localized: .Products.permissionBodyPreimageSubmit), - icon: makeIcon(systemName: "doc.text") - ) - case .statementSubmitAccess: - PromptContent( - title: String(localized: .Products.permissionTitleRemote(productId: productId)), - body: String(localized: .Products.permissionBodyStatementSubmit), - icon: makeIcon(systemName: "text.bubble") + title = String( + localized: .Products.permissionTitleDeviceCapability( + productId: productId, + capability: capabilityDisplayName(capability) + ) ) + body = permission.permissionDescription + case .networkAccess, .networkAccessBundle: + title = String(localized: .Products.permissionTitleNetworkAccess(productId: productId)) + body = permission.permissionDescription + case .accountAccess: + title = String(localized: .Products.permissionTitleAccountAccess(productId: productId)) + body = permission.permissionDescription + case .balanceAccess, + .webRtcAccess, + .chainSubmitAccess, + .preimageSubmitAccess, + .statementSubmitAccess, + .userIdentityAccess: + title = String(localized: .Products.permissionTitleRemote(productId: productId)) + body = permission.permissionDescription case let .jamPeersAccess(genesis): - PromptContent( - title: String( - localized: .Products.permissionTitleJamPeers( - productId: productId, - shortGenesis: ProductPermission.shortGenesis(genesis) - ) - ), - body: String(localized: .Products.permissionBodyJamPeers) + "\n\n" + genesis, - icon: makeIcon(systemName: "point.3.connected.trianglepath.dotted") - ) - case .userIdentityAccess: - PromptContent( - title: String(localized: .Products.permissionTitleRemote(productId: productId)), - body: String(localized: .Products.permissionBodyUserIdentityAccess), - icon: makeIcon(systemName: "person.text.rectangle") + title = String( + localized: .Products.permissionTitleJamPeers( + productId: productId, + shortGenesis: ProductPermission.shortGenesis(genesis) + ) ) + body = String(localized: .Products.permissionBodyJamPeers) + "\n\n" + genesis } + return PromptContent(title: title, body: body, icon: makeIcon(systemName: permission.permissionIconSystemName)) } static func makeBatchedContent( @@ -176,40 +123,7 @@ private extension ProductPermissionPromptViewFactory { } static func permissionDescription(for permission: ProductPermission) -> String { - switch permission { - case let .networkAccess(domain): - "- " + String( - localized: .Products.permissionBodyNetworkAccess(domain: domain) - ) - case let .networkAccessBundle(domains): - permissionDescription(for: .networkAccess(domain: domains.joined(separator: ", "))) - case .balanceAccess: - "- " + String(localized: .Products.permissionBodyBalanceAccess) - case .webRtcAccess: - "- " + String(localized: .Products.permissionBodyWebRtc) - case .chainSubmitAccess: - "- " + String(localized: .Products.permissionBodyChainSubmit) - case .preimageSubmitAccess: - "- " + String(localized: .Products.permissionBodyPreimageSubmit) - case .statementSubmitAccess: - "- " + String(localized: .Products.permissionBodyStatementSubmit) - case let .jamPeersAccess(genesis): - "- " + String( - localized: .Products.permissionLabelJamPeers( - genesis: genesis - ) - ) - case let .deviceCapability(capability): - "- " + capabilityDescription(capability) - case let .accountAccess(targetProductId): - "- " + String( - localized: .Products.permissionBodyAccountAccess( - targetProductId: targetProductId - ) - ) - case .userIdentityAccess: - "- " + String(localized: .Products.permissionBodyUserIdentityAccess) - } + "- " + permission.permissionDescription } static func makeAction( @@ -236,36 +150,6 @@ private extension ProductPermissionPromptViewFactory { } } - static func capabilityDescription(_ capability: DeviceCapabilityType) -> String { - switch capability { - case .notifications: String(localized: .Products.permissionCapabilityDescriptionNotifications) - case .camera: String(localized: .Products.permissionCapabilityDescriptionCamera) - case .microphone: String(localized: .Products.permissionCapabilityDescriptionMicrophone) - case .bluetooth: String(localized: .Products.permissionCapabilityDescriptionBluetooth) - case .nfc: String(localized: .Products.permissionCapabilityDescriptionNfc) - case .location: String(localized: .Products.permissionCapabilityDescriptionLocation) - case .clipboard: String(localized: .Products.permissionCapabilityDescriptionClipboard) - case .openUrl: String(localized: .Products.permissionCapabilityDescriptionOpenUrl) - case .biometrics: String(localized: .Products.permissionCapabilityDescriptionBiometrics) - } - } - - static func iconForCapability(_ capability: DeviceCapabilityType) -> UIImage? { - let name = - switch capability { - case .notifications: "bell.fill" - case .camera: "camera.fill" - case .microphone: "mic.fill" - case .bluetooth: "antenna.radiowaves.left.and.right" - case .nfc: "wave.3.right" - case .location: "location.fill" - case .clipboard: "doc.on.clipboard.fill" - case .openUrl: "safari.fill" - case .biometrics: "faceid" - } - return makeIcon(systemName: name) - } - static func makeIcon(systemName: String) -> UIImage? { let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .regular) return UIImage(systemName: systemName, withConfiguration: config)? diff --git a/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift b/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift index 6096dc79ee..4dc902baee 100644 --- a/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift @@ -13,82 +13,70 @@ final class AppPermissionsViewModelFactory { extension AppPermissionsViewModelFactory: AppPermissionsViewModelMaking { func createItems(from grants: [ProductPermissionGrant]) -> [AppPermissionsViewLayout.Item] { grants.map { grant in - let display = displayInfo(for: grant.permission) - return AppPermissionsViewLayout.Item( + AppPermissionsViewLayout.Item( id: grant.identifier, - title: display.title, - description: display.description, + title: grant.permission.settingsTitle, + description: grant.permission.permissionDescription, isOn: true ) } } } -private extension AppPermissionsViewModelFactory { - typealias DisplayInfo = (title: String, description: String) - - func displayInfo(for permission: ProductPermission) -> DisplayInfo { - switch permission { - case let .deviceCapability(capability): - (capabilityTitle(capability), capabilityDescription(capability)) +/// Shared descriptions keep consent prompts and the revocation screen in agreement. +extension ProductPermission { + var permissionDescription: String { + switch self { + case let .deviceCapability(capability): capabilityDescription(capability) case let .networkAccess(domain): - ( - String(localized: .Products.appPermissionNetworkTitle), - String(localized: .Products.permissionBodyNetworkAccess(domain: domain)) - ) + String(localized: .Products.permissionBodyNetworkAccess(domain: domain)) case let .networkAccessBundle(domains): - displayInfo(for: .networkAccess(domain: domains.joined(separator: ", "))) + String(localized: .Products.permissionBodyNetworkAccess(domain: domains.joined(separator: ", "))) case let .accountAccess(targetProductId): - ( - String(localized: .Products.appPermissionAccountTitle), - String( - localized: .Products.permissionBodyAccountAccess( - targetProductId: targetProductId - ) - ) - ) - case .balanceAccess: - ( - String(localized: .Products.appPermissionBalanceTitle), - String(localized: .Products.permissionBodyBalanceAccess) - ) - case .webRtcAccess: - ( - String(localized: .Products.appPermissionWebRtcTitle), - String(localized: .Products.permissionBodyWebRtc) - ) - case .chainSubmitAccess: - ( - String(localized: .Products.appPermissionChainSubmitTitle), - String(localized: .Products.permissionBodyChainSubmit) - ) - case .preimageSubmitAccess: - ( - String(localized: .Products.appPermissionPreimageSubmitTitle), - String(localized: .Products.permissionBodyPreimageSubmit) - ) - case .statementSubmitAccess: - ( - String(localized: .Products.appPermissionStatementSubmitTitle), - String(localized: .Products.permissionBodyStatementSubmit) - ) + String(localized: .Products.permissionBodyAccountAccess(targetProductId: targetProductId)) + case .balanceAccess: String(localized: .Products.permissionBodyBalanceAccess) + case .webRtcAccess: String(localized: .Products.permissionBodyWebRtc) + case .chainSubmitAccess: String(localized: .Products.permissionBodyChainSubmit) + case .preimageSubmitAccess: String(localized: .Products.permissionBodyPreimageSubmit) + case .statementSubmitAccess: String(localized: .Products.permissionBodyStatementSubmit) case let .jamPeersAccess(genesis): - ( - String(localized: .Products.appPermissionJamPeersTitle), - String( - localized: .Products.permissionLabelJamPeers( - genesis: genesis - ) - ) - ) - case .userIdentityAccess: - ( - String(localized: .Products.appPermissionUserIdentityTitle), - String(localized: .Products.permissionBodyUserIdentityAccess) - ) + String(localized: .Products.permissionLabelJamPeers(genesis: genesis)) + case .userIdentityAccess: String(localized: .Products.permissionBodyUserIdentityAccess) } } + var permissionIconSystemName: String { + switch self { + case let .deviceCapability(capability): capabilityIcon(capability) + case .networkAccess, .networkAccessBundle: "globe" + case .accountAccess: "person.crop.circle" + case .balanceAccess: "dollarsign.circle.fill" + case .webRtcAccess: "video.fill" + case .chainSubmitAccess: "link" + case .preimageSubmitAccess: "doc.text" + case .statementSubmitAccess: "text.bubble" + case .jamPeersAccess: "point.3.connected.trianglepath.dotted" + case .userIdentityAccess: "person.text.rectangle" + } + } + + var settingsTitle: String { + switch self { + case let .deviceCapability(capability): capabilityTitle(capability) + case .networkAccess, .networkAccessBundle: String(localized: .Products.appPermissionNetworkTitle) + case .accountAccess: String(localized: .Products.appPermissionAccountTitle) + case .balanceAccess: String(localized: .Products.appPermissionBalanceTitle) + case .webRtcAccess: String(localized: .Products.appPermissionWebRtcTitle) + case .chainSubmitAccess: String(localized: .Products.appPermissionChainSubmitTitle) + case .preimageSubmitAccess: String(localized: .Products.appPermissionPreimageSubmitTitle) + case .statementSubmitAccess: String(localized: .Products.appPermissionStatementSubmitTitle) + case .jamPeersAccess: String(localized: .Products.appPermissionJamPeersTitle) + case .userIdentityAccess: String(localized: .Products.appPermissionUserIdentityTitle) + } + } +} + +private extension ProductPermission { func capabilityTitle(_ capability: DeviceCapabilityType) -> String { switch capability { case .notifications: String(localized: .Products.appPermissionCapabilityNotifications) @@ -116,4 +104,18 @@ private extension AppPermissionsViewModelFactory { case .biometrics: String(localized: .Products.permissionCapabilityDescriptionBiometrics) } } + + func capabilityIcon(_ capability: DeviceCapabilityType) -> String { + switch capability { + case .notifications: "bell.fill" + case .camera: "camera.fill" + case .microphone: "mic.fill" + case .bluetooth: "antenna.radiowaves.left.and.right" + case .nfc: "wave.3.right" + case .location: "location.fill" + case .clipboard: "doc.on.clipboard.fill" + case .openUrl: "safari.fill" + case .biometrics: "faceid" + } + } } From e2072c85fca0f7d417f35341be96838a05efa8b7 Mon Sep 17 00:00:00 2001 From: w Date: Thu, 8 Oct 2026 23:32:52 -0400 Subject: [PATCH 35/36] docs(ios): explain shared scoped permission presentation --- hosts/ios/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hosts/ios/README.md b/hosts/ios/README.md index 4ce7300f65..98a4d8211c 100644 --- a/hosts/ios/README.md +++ b/hosts/ios/README.md @@ -122,6 +122,8 @@ Polkadot iOS is a self-custodial superapp: your keys are created on your phone, Built with **UIKit** and programmatic layout (no Storyboards), using **VIPER** for every feature module: code is split between the main app target and 28 local Swift packages under [`Packages/`](./Packages) with `AppDependencies` as the root package, chain access goes through [substrate-sdk-ios](https://github.com/novasamatech/substrate-sdk-ios) (JSON-RPC, storage subscriptions, extrinsics), and local data lives in CoreData. +Permission prompts and revocation settings share the `ProductPermission` presentation metadata so capability descriptions stay consistent. JAM peer consent displays the full network genesis and remains scoped to that network and product; it does not grant accounts or signing. + This repository ships a **GitHub Actions + Fastlane CI/CD setup** — PR build and tests, plus maintainer-gated TestFlight and Firebase App Distribution. Build-time configuration, signing, the required secrets, and the pipeline itself are From ec8a8080343b06cef9f884299558bc5bf638a264 Mon Sep 17 00:00:00 2001 From: w Date: Fri, 9 Oct 2026 02:36:23 -0400 Subject: [PATCH 36/36] fix(ios): handle throwing JAM permission conversion in require --- .../Products/ProductPermissionRepositoryTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hosts/ios/polkadot-appTests/Products/ProductPermissionRepositoryTests.swift b/hosts/ios/polkadot-appTests/Products/ProductPermissionRepositoryTests.swift index 828322bf6b..9ff4a76882 100644 --- a/hosts/ios/polkadot-appTests/Products/ProductPermissionRepositoryTests.swift +++ b/hosts/ios/polkadot-appTests/Products/ProductPermissionRepositoryTests.swift @@ -541,7 +541,7 @@ extension ProductPermissionRepositoryTests { @Test("JAM permission mapping preserves the full genesis and rejects short keys") func canonicalJamPermissionMapping() throws { let permission = ProductPermission.jamPeersAccess(genesis: "0x" + String(repeating: "ab", count: 32)) - let request = try #require(permission.authorizationRequest()) + let request = try #require(try permission.authorizationRequest()) #expect(try ProductPermission.fromAuthorization(request) == [permission]) #expect(throws: ProductPermissionMappingError.self) { try ProductPermission.jamPeersAccess(genesis: "0xab").authorizationRequest()