From bce15dc3e0af19ac2e9b0a9ba95816bc73f72b5b Mon Sep 17 00:00:00 2001 From: Deggen Date: Thu, 30 Jul 2026 13:17:32 -0500 Subject: [PATCH 1/7] feat(air-gap): add @bsv/air-gap optical transport v0.1.0 Adds a zero-dependency, payload-agnostic one-directional optical air-gap transport: arbitrary bytes in, a deterministic endless sequence of fountain-coded wire parts out, reassembled from a camera feed with no back-channel. Wire format: `air-gap:` + unpadded base64url of a 14-byte big-endian header (seq u32, K u16, msgLen u32, crc32 u32) and exactly one fixed-size block. The first K parts are the source blocks verbatim, so one clean camera cycle decodes with zero overhead; later parts are interchangeable XOR mixes chosen by an xorshift32 RNG seeded from seq with an ideal-soliton degree, so any K + epsilon distinct parts reconstruct the message and a missed frame costs almost nothing. The coding is ported bit-for-bit from bsv-browser's `bsvpayf2:` fountain so a later migration and any cross-language port stay interoperable; the frozen part strings in tests/vectors.test.ts are the conformance contract. The legacy prefix is not accepted. Decoder guarantees, because the input is a camera: - accept() never throws; stray reads are ordinary { ok: false } no-ops - no partial or unverified bytes ever escape; message() is CRC32-gated - a CRC mismatch discards the assembly and self-resets, so a looping sender refills it without the application managing a retry - the session block size is pinned by the first accepted part, which is what stops two senders, or one padded frame, from being assembled together Encoder guards empty and oversize messages, non-integer block sizes, block counts past the u16 K field, and sequence numbers outside u32. Out of scope by design, and deliberately absent: QR rendering, camera capture, display cadence (no frame-interval constant is exported), payment or BRC-100 schemas, compression, encryption. Tests: 100 unit and vector cases plus a fast-check property suite over the wire boundary; 100% statement, branch and line coverage of src; 89% mutation score against an 85 floor. Governance: registered in repository-health, package-release-notes, test-quality property suites, mutation targets and npm supply chain; package count baselines and the docs package page and indexes updated to 31 public packages. Co-Authored-By: Claude Opus 5 (1M context) --- docs-site/src/lib/nav.ts | 1 + docs/packages/helpers/air-gap.md | 160 ++++++++++++ docs/packages/helpers/index.md | 1 + docs/packages/index.md | 1 + docs/reference/package-api-migrations.md | 14 +- docs/reference/stack-facts.md | 11 +- governance/mutation-testing/policy.json | 10 + governance/mutation-testing/targets.mjs | 9 + governance/npm-package-supply-chain.json | 2 +- governance/package-release-notes.json | 7 + governance/repository-health/baselines.json | 7 +- governance/repository-health/projects.json | 11 + governance/test-quality/policy.json | 15 ++ packages/helpers/air-gap/.gitattributes | 2 + packages/helpers/air-gap/.gitignore | 4 + packages/helpers/air-gap/LICENSE.txt | 58 +++++ packages/helpers/air-gap/README.md | 112 +++++++++ packages/helpers/air-gap/jest.config.cjs | 24 ++ packages/helpers/air-gap/package.json | 78 ++++++ packages/helpers/air-gap/src/base64url.ts | 50 ++++ packages/helpers/air-gap/src/coding.ts | 74 ++++++ packages/helpers/air-gap/src/constants.ts | 46 ++++ packages/helpers/air-gap/src/crc32.ts | 34 +++ packages/helpers/air-gap/src/decoder.ts | 231 ++++++++++++++++++ packages/helpers/air-gap/src/encoder.ts | 108 ++++++++ packages/helpers/air-gap/src/errors.ts | 17 ++ packages/helpers/air-gap/src/helpers.ts | 36 +++ packages/helpers/air-gap/src/index.ts | 19 ++ .../tests/airGapCodec.property.test.ts | 158 ++++++++++++ packages/helpers/air-gap/tests/api.test.ts | 98 ++++++++ .../helpers/air-gap/tests/base64url.test.ts | 50 ++++ packages/helpers/air-gap/tests/crc32.test.ts | 40 +++ .../helpers/air-gap/tests/decoder.test.ts | Bin 0 -> 12914 bytes .../helpers/air-gap/tests/encoder.test.ts | 164 +++++++++++++ packages/helpers/air-gap/tests/helpers.ts | 95 +++++++ .../helpers/air-gap/tests/roundtrip.test.ts | 131 ++++++++++ .../helpers/air-gap/tests/vectors.test.ts | 169 +++++++++++++ packages/helpers/air-gap/tsconfig.build.json | 10 + packages/helpers/air-gap/tsconfig.json | 26 ++ .../helpers/air-gap/tsconfig.typecheck.json | 11 + pnpm-lock.yaml | 30 +++ scripts/check-package-license-tarballs.mjs | 4 +- scripts/package-documentation.mjs | 2 +- scripts/package-documentation.test.mjs | 4 +- scripts/package-license-policy.test.mjs | 2 +- scripts/package-release-artifacts.mjs | 4 +- scripts/repository-health.test.mjs | 14 +- scripts/test-governance.test.mjs | 8 +- scripts/typescript-toolchain.test.mjs | 2 +- 49 files changed, 2134 insertions(+), 30 deletions(-) create mode 100644 docs/packages/helpers/air-gap.md create mode 100644 packages/helpers/air-gap/.gitattributes create mode 100644 packages/helpers/air-gap/.gitignore create mode 100644 packages/helpers/air-gap/LICENSE.txt create mode 100644 packages/helpers/air-gap/README.md create mode 100644 packages/helpers/air-gap/jest.config.cjs create mode 100644 packages/helpers/air-gap/package.json create mode 100644 packages/helpers/air-gap/src/base64url.ts create mode 100644 packages/helpers/air-gap/src/coding.ts create mode 100644 packages/helpers/air-gap/src/constants.ts create mode 100644 packages/helpers/air-gap/src/crc32.ts create mode 100644 packages/helpers/air-gap/src/decoder.ts create mode 100644 packages/helpers/air-gap/src/encoder.ts create mode 100644 packages/helpers/air-gap/src/errors.ts create mode 100644 packages/helpers/air-gap/src/helpers.ts create mode 100644 packages/helpers/air-gap/src/index.ts create mode 100644 packages/helpers/air-gap/tests/airGapCodec.property.test.ts create mode 100644 packages/helpers/air-gap/tests/api.test.ts create mode 100644 packages/helpers/air-gap/tests/base64url.test.ts create mode 100644 packages/helpers/air-gap/tests/crc32.test.ts create mode 100644 packages/helpers/air-gap/tests/decoder.test.ts create mode 100644 packages/helpers/air-gap/tests/encoder.test.ts create mode 100644 packages/helpers/air-gap/tests/helpers.ts create mode 100644 packages/helpers/air-gap/tests/roundtrip.test.ts create mode 100644 packages/helpers/air-gap/tests/vectors.test.ts create mode 100644 packages/helpers/air-gap/tsconfig.build.json create mode 100644 packages/helpers/air-gap/tsconfig.json create mode 100644 packages/helpers/air-gap/tsconfig.typecheck.json diff --git a/docs-site/src/lib/nav.ts b/docs-site/src/lib/nav.ts index 846132835..432b73fb1 100644 --- a/docs-site/src/lib/nav.ts +++ b/docs-site/src/lib/nav.ts @@ -111,6 +111,7 @@ export const NAV: NavSection[] = [ { label: '@bsv/did-client', href: '/packages/helpers/did-client/' }, { label: '@bsv/wallet-helper', href: '/packages/helpers/wallet-helper/' }, { label: '@bsv/amountinator', href: '/packages/helpers/amountinator/' }, + { label: '@bsv/air-gap', href: '/packages/helpers/air-gap/' }, { label: '@bsv/fund-wallet', href: '/packages/helpers/fund-wallet/' } ] } diff --git a/docs/packages/helpers/air-gap.md b/docs/packages/helpers/air-gap.md new file mode 100644 index 000000000..f101f267a --- /dev/null +++ b/docs/packages/helpers/air-gap.md @@ -0,0 +1,160 @@ +--- +id: pkg-air-gap +title: '@bsv/air-gap' +kind: package +domain: helpers +version: '0.1.0' +source_repo: 'bsv-blockchain/ts-stack' +last_updated: '2026-07-30' +last_verified: '2026-07-30' +review_cadence_days: 30 +npm: 'https://www.npmjs.com/package/@bsv/air-gap' +repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/air-gap' +status: stable +tags: [helpers, air-gap, qr, optical] +--- + +# @bsv/air-gap + +> One-directional optical air-gap transport for arbitrary bytes — fountain-coded QR parts that survive missed camera frames with no back-channel, zero runtime dependencies, and CRC32 payload integrity. + +## Install + +```bash +npm install @bsv/air-gap +``` + +No peer dependencies. Browsers and Node.js 22 or newer. + +## Quick start + +```typescript +import { AirGapDecoder, AirGapEncoder } from '@bsv/air-gap' + +// Sender: display parts. The application owns the sequence number and cadence. +const encoder = new AirGapEncoder(payloadBytes) +let seq = 0 +setInterval(() => renderQrCode(encoder.partAt(seq++)), 200) + +// Receiver: feed every scan in. +const decoder = new AirGapDecoder() +onBarcodeScan(text => { + const { ok, done, have, total } = decoder.accept(text) + if (ok) showProgress(have, total) + if (!done) return + const payload = decoder.message() // verified bytes, or null + if (payload) finish(payload) +}) +``` + +## What it provides + +- **AirGapEncoder** — Splits a message into `K` source blocks and renders part `seq` as a wire string; pure function of `(message, blockBytes, seq)` +- **AirGapDecoder** — Accepts scanned strings, peels fountain parts, and emits the CRC-verified payload +- **Systematic prefix** — The first `K` parts are the source blocks verbatim, so one clean camera cycle decodes with zero overhead +- **Loss tolerance** — Later parts are interchangeable XOR mixes; any `K + ε` distinct parts reconstruct the message +- **Camera safety** — `accept` never throws and never emits partial or unverified bytes +- **Session isolation** — `(K, msgLen, crc32)` identifies a message; a foreign part resets the decoder instead of blending +- **Block-size pin** — The first accepted part fixes the payload length for the session, rejecting mismatched frames +- **Zero runtime dependencies** — No `@bsv/sdk`, no polyfills beyond `btoa` / `atob` + +## Runtime and package compatibility + +The package root provides matching typed entry points for Node.js ESM and +CommonJS consumers. The published tarball is checked with `publint`, strict +`@arethetypeswrong/core` resolution, and clean installs that import and +require every public export. Node.js 22 or newer is supported. + +## Common patterns + +### Animate a multi-part message + +```typescript +const encoder = new AirGapEncoder(payload, 1200) +let seq = 0 +const timer = setInterval(() => renderQrCode(encoder.partAt(seq++)), 200) +// seq is unbounded: keep looping until the receiver signals success out-of-band +``` + +### Display a single-part message statically + +```typescript +const encoder = new AirGapEncoder(shortPayload) +if (encoder.blockCount === 1) renderQrCode(encoder.partAt(0)) // no animation needed +``` + +### Route camera reads cheaply + +```typescript +import { isAirGapPart } from '@bsv/air-gap' + +onBarcodeScan(text => { + if (!isAirGapPart(text)) return handleOtherQr(text) + decoder.accept(text) +}) +``` + +### Size a part against a QR version + +```typescript +import { estimatePartCharLength } from '@bsv/air-gap' + +estimatePartCharLength(1200) // 1627 characters, exactly +``` + +### Abandon a scan + +```typescript +onCancel(() => decoder.reset()) +``` + +## Key concepts + +- **Fountain coding** — Luby-transform parts with an ideal-soliton degree, not numbered chunks; a missed frame costs almost nothing +- **Determinism** — Part contents are a pure function of `seq`; the decoder rebuilds each part's block set from the header alone +- **Wire part** — `air-gap:` + unpadded base64url of a 14-byte big-endian header (`seq` u32, `K` u16, `msgLen` u32, `crc32` u32) and exactly one block +- **Block size off the wire** — Inferred from payload length, so every part is the same size and each application picks its own symbol density +- **Session key** — `(K, msgLen, crc32)`; a change means a different message and a full decoder reset +- **Fail closed** — A CRC mismatch discards the assembly and resets, so a still-looping sender simply refills the decoder + +## When to use this + +- Moving a payload between two devices with no shared network, over a screen and a camera +- Signing requests, wallet payloads, or configuration blobs handed to an offline signer +- Any transfer that must survive dropped frames without a back-channel or retry protocol +- Payloads up to `MAX_MESSAGE_BYTES` (64 KiB), realistically a few hundred bytes to a few KiB + +## When NOT to use this + +- Bidirectional transfers — this transport has no acknowledgement channel +- Payloads larger than a few KiB — send a reference and fetch the bytes over a real network +- Confidentiality or authenticity — CRC32 is an integrity check, not a MAC; encrypt and sign inside the payload +- QR rendering or camera capture — bring your own; this package only produces and consumes strings +- Interoperating with BRC-225 TKQR1, BC-UR (`ur:`), or the legacy `bsvpayf2:` prefix — none share this wire format + +## Spec conformance + +- **CRC-32** — IEEE 802.3, polynomial `0xedb88320`, check value `0xCBF43926` for ASCII `123456789` +- **base64url** — RFC 4648 §5, unpadded +- **Conformance vectors** — Frozen part strings in `tests/vectors.test.ts`; any implementation must reproduce them byte for byte +- **BRC** — The wire format is intended for a future BRC; no number is assigned yet + +## Common pitfalls + +- **Waiting for `done` before reading** — `message()` returns `null` until every block is recovered; check `done` first +- **Treating `message() === null` as fatal** — After a CRC failure it means "keep scanning"; the decoder has already reset itself +- **Changing `blockBytes` mid-stream** — The decoder pins the first accepted payload length and rejects the rest of the session +- **Exporting a display interval from this package** — There is none by design; the application owns its animation loop +- **Passing an empty or oversize message** — The encoder throws `AirGapError`; validate before constructing + +## Related packages + +- [@bsv/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) — Transaction and payload construction for the bytes this transport carries +- [@bsv/simple](simple.md) — Wallet facade that can originate air-gapped payloads +- [@bsv/wallet-helper](wallet-helper.md) — Wallet plumbing above the transport + +## Reference + +- [API reference (TypeDoc)](https://bsv-blockchain.github.io/ts-stack/api/air-gap/) +- [Source on GitHub](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/air-gap) +- [npm](https://www.npmjs.com/package/@bsv/air-gap) diff --git a/docs/packages/helpers/index.md b/docs/packages/helpers/index.md index e9c3a3ebd..3aa36c47e 100644 --- a/docs/packages/helpers/index.md +++ b/docs/packages/helpers/index.md @@ -23,6 +23,7 @@ Utility libraries and helper tools for common BSV operations. Includes high-leve | [@bsv/templates](templates.md) | Predefined ScriptTemplate examples for protocol engineers, including OpReturn, MultiPushDrop, and P2MSKH lock/unlock patterns | | [@bsv/did](did.md) | SD-JWT VC and optional `did:key` helpers for BSV SDK identity keys | | [@bsv/did-client](did-client.md) | DID client for creating, revoking, and querying on-chain DIDs with overlay broadcast | +| [@bsv/air-gap](air-gap.md) | One-directional optical air-gap transport — fountain-coded QR parts for arbitrary bytes | | [@bsv/amountinator](amountinator.md) | Multi-currency converter (SATS↔BSV↔15+ fiat) with exchange rate caching | | [@bsv/fund-wallet](fund-wallet.md) | CLI faucet for funding wallets from Metanet Desktop during development and testing | | [create-bsv-app](create-bsv-app.md) | CLI and starter catalogue for React, Express, full-stack, and maintained example applications | diff --git a/docs/packages/index.md b/docs/packages/index.md index 276d9a407..73dba4c87 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -83,6 +83,7 @@ Independent HTTP 402 flow: - [@bsv/did-client](./helpers/did-client.md) — DID resolver (Decentralized Identifiers) - [@bsv/simple](./helpers/simple.md) — Simplified API for common operations - [@bsv/wallet-helper](./helpers/wallet-helper.md) — Wallet utility functions +- [@bsv/air-gap](./helpers/air-gap.md) — Fountain-coded optical QR transport for arbitrary bytes - [@bsv/amountinator](./helpers/amountinator.md) — Satoshi/BSV conversion and formatting - [@bsv/fund-wallet](./helpers/fund-wallet.md) — Faucet integration for testnet/devnet - [create-bsv-app](./helpers/create-bsv-app.md) — CLI and starter catalogue for new applications diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 5edde9a3d..47a22b4c0 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -12,7 +12,7 @@ tags: [reference, packages, api, declarations, migrations, release-notes] # Package API, Declarations, and Migration Ledger -This page is generated from all 30 public manifests, package documentation, and +This page is generated from all 31 public manifests, package documentation, and `governance/package-release-notes.json`. It records source candidates without publishing them. CI rejects a version change unless its release classification, summary, and migration guidance are updated at the same time. @@ -26,6 +26,7 @@ and clean-consumer tests remain the executable type authority. | Package | npm baseline | Source | Candidate | API | Migration | | --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@bsv/402-pay` | `0.2.1` | `0.2.4` | patch | [API and usage](../packages/middleware/402-pay.md) | No consumer migration is required; client and server exports, payment protocol behavior, and runtime defaults are unchanged. | +| `@bsv/air-gap` | `0.0.0` | `0.1.0` | minor | [API and usage](../packages/helpers/air-gap.md) | No consumer migration is required; this is the first published release of a new package with no prior public API. | | `@bsv/amountinator` | `2.1.1` | `2.1.4` | patch | [API and usage](../packages/helpers/amountinator.md) | No consumer migration is required; this is a backward-compatible patch candidate. | | `@bsv/auth` | `0.1.1` | `0.1.3` | patch | [API and usage](../packages/middleware/auth.md) | No consumer migration is required; authentication APIs, wire behavior, and runtime defaults are unchanged. | | `@bsv/auth-express-middleware` | `2.1.2` | `2.1.5` | patch | [API and usage](../packages/middleware/auth-express-middleware.md) | No consumer migration is required; existing public CORS defaults and middleware APIs are retained. | @@ -76,6 +77,17 @@ explicitly authorized operations. | `./server` | `./dist/server.mjs`
`./dist/server.cjs` | `./dist/server.d.mts`
`./dist/server.d.cts` | | `./client` | `./dist/client.mjs`
`./dist/client.cjs` | `./dist/client.d.mts`
`./dist/client.d.cts` | +## @bsv/air-gap + +- Package documentation: [docs/packages/helpers/air-gap.md](../packages/helpers/air-gap.md) +- Source: [packages/helpers/air-gap](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/air-gap) +- Release note: Introduces the zero-dependency one-directional optical air-gap transport: fountain-coded wire parts for arbitrary bytes, CRC32 payload integrity, a camera-safe decoder that never throws, and dual CJS/ESM builds. +- Migration: No consumer migration is required; this is the first published release of a new package with no prior public API. + +| Public subpath | Runtime target(s) | Declaration target(s) | +| -------------- | ---------------------------------------- | -------------------------------------------- | +| `.` | `./dist/index.mjs`
`./dist/index.cjs` | `./dist/index.d.mts`
`./dist/index.d.cts` | + ## @bsv/amountinator - Package documentation: [docs/packages/helpers/amountinator.md](../packages/helpers/amountinator.md) diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index 30564224e..94f981b6b 100644 --- a/docs/reference/stack-facts.md +++ b/docs/reference/stack-facts.md @@ -31,12 +31,13 @@ Node consumers; they do not require a browser or mobile device to provide Node A ## Public package manifest -The release graph currently contains **30 public packages**. Versions +The release graph currently contains **31 public packages**. Versions below are source-manifest versions; registry publication is a separate, explicitly authorized release action. | Area | Package | Source version | Project profile | Consumer profiles | Runtime targets | Node engine | Source | | --- | --- | --- | --- | --- | --- | --- | --- | +| helpers | `@bsv/air-gap` | `0.1.0` | node-library | node-cjs, node-esm | node | `>=22` | [packages/helpers/air-gap](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/air-gap) | | helpers | `@bsv/amountinator` | `2.1.4` | node-library | node-cjs, node-esm | node | `>=22` | [packages/helpers/amountinator](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/amountinator) | | helpers | `@bsv/did` | `0.2.4` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/did](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did) | | helpers | `@bsv/did-client` | `1.2.3` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/helpers/did-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did-client) | @@ -87,9 +88,9 @@ the separately released and verified image digest. | Metric | Count | | --- | --- | -| Governed projects | 37 | -| Package-area projects | 33 | -| Public npm packages | 30 | +| Governed projects | 38 | +| Package-area projects | 34 | +| Public npm packages | 31 | | Private package-area projects | 3 | | Standalone infrastructure projects | 7 | @@ -124,7 +125,7 @@ targets have been completed. | Metric | Current value | Authority | | --- | --- | --- | -| Projects with a test:coverage script | 32 | current package manifests | +| Projects with a test:coverage script | 33 | current package manifests | | Aggregate line coverage | 66.97% | https://app.codecov.io/gh/BSV-blockchain/ts-stack | | Reported source files | 543 | https://app.codecov.io/gh/BSV-blockchain/ts-stack | | Reported lines (hit / missed / partial) | 30981 / 11619 / 3659 | https://app.codecov.io/gh/BSV-blockchain/ts-stack | diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index 4fef99a28..0e83538ef 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -167,6 +167,16 @@ "maximumNoCoverage": 0, "maximumInvalid": 0 }, + { + "id": "air-gap-codec", + "manifest": "packages/helpers/air-gap/package.json", + "propertyTest": "packages/helpers/air-gap/tests/airGapCodec.property.test.ts", + "risk": "high", + "boundary": "Camera-supplied optical wire parts decoded into application payload bytes", + "minimumScore": 85, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, { "id": "amount-format", "manifest": "packages/helpers/amountinator/package.json", diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 2aa0456ac..54eb4eb86 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -232,6 +232,15 @@ export function buildMutationTargets(repositoryRoot) { { esm: true } ) }, + 'air-gap-codec': { + packageDirectory: 'packages/helpers/air-gap', + manifest: 'packages/helpers/air-gap/package.json', + propertyTest: 'packages/helpers/air-gap/tests/airGapCodec.property.test.ts', + mutate: ['src/decoder.ts', 'src/encoder.ts', 'src/coding.ts', 'src/base64url.ts'], + // No testMatch override: the whole suite is fast and every file bears on + // the wire boundary, so every file gets to kill mutants. + ...jestTarget('jest.config.cjs') + }, 'amount-format': { packageDirectory: 'packages/helpers/amountinator', manifest: 'packages/helpers/amountinator/package.json', diff --git a/governance/npm-package-supply-chain.json b/governance/npm-package-supply-chain.json index 07dae64bc..0e81b2ff0 100644 --- a/governance/npm-package-supply-chain.json +++ b/governance/npm-package-supply-chain.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "artifactSchemaVersion": 1, - "publicPackageCount": 30, + "publicPackageCount": 31, "releaseWorkflow": ".github/workflows/release.yaml", "releaseEnvironment": "npm-production", "buildRuntime": { diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 9f65e5085..e7b8bd45f 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -10,6 +10,13 @@ "summary": "Adds an exact-tarball Vite and esbuild contract for the browser-safe client entry point, including a bundle-size ratchet and an assertion that server exports never leak into browser consumers.", "migration": "No consumer migration is required; client and server exports, payment protocol behavior, and runtime defaults are unchanged." }, + { + "name": "@bsv/air-gap", + "publishedVersion": "0.0.0", + "releaseType": "minor", + "summary": "Introduces the zero-dependency one-directional optical air-gap transport: fountain-coded wire parts for arbitrary bytes, CRC32 payload integrity, a camera-safe decoder that never throws, and dual CJS/ESM builds.", + "migration": "No consumer migration is required; this is the first published release of a new package with no prior public API." + }, { "name": "@bsv/amountinator", "publishedVersion": "2.1.1", diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index 9df6efe99..6125674eb 100644 --- a/governance/repository-health/baselines.json +++ b/governance/repository-health/baselines.json @@ -4,9 +4,9 @@ "sourceRevision": "f9137ff037c6d608019d04b4e2f984812b0385b7", "tracker": "https://github.com/bsv-blockchain/ts-stack/issues/324", "workspace": { - "projects": 37, - "packageAreaProjects": 33, - "publicPackages": 30, + "projects": 38, + "packageAreaProjects": 34, + "publicPackages": 31, "privatePackageAreaProjects": 3 }, "ci": { @@ -297,6 +297,7 @@ ] }, "publicPackageVersions": { + "@bsv/air-gap": "0.1.0", "@bsv/amountinator": "2.1.4", "@bsv/wallet-helper": "0.1.6", "create-bsv-app": "1.0.4", diff --git a/governance/repository-health/projects.json b/governance/repository-health/projects.json index 0ec021702..2e92540c5 100644 --- a/governance/repository-health/projects.json +++ b/governance/repository-health/projects.json @@ -357,6 +357,17 @@ "runtimeTargets": ["browser", "node"], "release": "none" }, + { + "path": "packages/helpers/air-gap", + "name": "@bsv/air-gap", + "owner": "ts-stack-maintainers", + "area": "helpers", + "profile": "node-library", + "consumerProfiles": ["node-cjs", "node-esm"], + "criticality": "tier-2", + "runtimeTargets": ["node"], + "release": "npm-oidc" + }, { "path": "packages/helpers/amountinator", "name": "@bsv/amountinator", diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index 9a13e877e..51b8b7457 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -28,6 +28,7 @@ "packages/overlays/overlay/package.json", "packages/verifast/package.json", "packages/wallet/btms/package.json", + "packages/helpers/air-gap/package.json", "packages/helpers/amountinator/package.json", "packages/helpers/fund-wallet/package.json", "packages/middleware/402-pay/package.json", @@ -233,6 +234,20 @@ "Arbitrary message text never escapes the tolerant parser." ] }, + { + "path": "packages/helpers/air-gap/tests/airGapCodec.property.test.ts", + "manifest": "packages/helpers/air-gap/package.json", + "risk": "high", + "boundary": "Camera-supplied optical wire parts decoded into application payload bytes", + "target": "Fountain wire round trips under arbitrary frame loss, decoder totality on arbitrary scanned text, and fail-closed CRC integrity on corrupted parts", + "invariants": [ + "Every non-empty byte payload round-trips through one systematic part cycle.", + "Arbitrary repeating frame-loss patterns still reconstruct the exact payload.", + "Arbitrary scanned text never throws and never completes a message.", + "A corrupted part yields either the exact original bytes or nothing at all.", + "Every rendered part has the exact predicted character length for its block size." + ] + }, { "path": "packages/helpers/amountinator/tests/amountFormat.property.test.ts", "manifest": "packages/helpers/amountinator/package.json", diff --git a/packages/helpers/air-gap/.gitattributes b/packages/helpers/air-gap/.gitattributes new file mode 100644 index 000000000..dfe077042 --- /dev/null +++ b/packages/helpers/air-gap/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/packages/helpers/air-gap/.gitignore b/packages/helpers/air-gap/.gitignore new file mode 100644 index 000000000..0779208bb --- /dev/null +++ b/packages/helpers/air-gap/.gitignore @@ -0,0 +1,4 @@ +node_modules +out +dist +build \ No newline at end of file diff --git a/packages/helpers/air-gap/LICENSE.txt b/packages/helpers/air-gap/LICENSE.txt new file mode 100644 index 000000000..15e819500 --- /dev/null +++ b/packages/helpers/air-gap/LICENSE.txt @@ -0,0 +1,58 @@ +Open BSV License Version 6 – granted by BSV Association, Alpenstrasse 15, 6300 +Zug, Switzerland (CHE-427.008.338) ("Licensor"), to you as a user (henceforth +"You", "User" or "Licensee"). + +For the purposes of this license, the definitions below have the following +meanings: + +"Bitcoin Protocol" means the protocol implementation, cryptographic rules, +network protocols, and consensus mechanisms in the Bitcoin White Paper as +described here https://protocol.bsvblockchain.org. + +"Bitcoin White Paper" means the paper entitled 'Bitcoin: A Peer-to-Peer +Electronic Cash System' published by 'Satoshi Nakamoto' in October 2008. + +"BSV Blockchain" means: + + (a) the Bitcoin blockchain containing block height #556767 with the hash + "000000000000000001d956714215d96ffc00e0afda4cd0a96c96f8d802b1662b" and + that contains the longest honest persistent chain of blocks which has been + produced in a manner which is consistent with the rules set forth in the + Network Access Rules; and + (b) the test blockchains that contain the longest honest persistent chains of + blocks which has been produced in a manner which is consistent with the + rules set forth in the Network Access Rules. + +"Network Access Rules" or "Rules" means the set of rules regulating the +relationship between BSV Association and the nodes on BSV based on the Bitcoin +Protocol rules and those set out in the Bitcoin White Paper, and available here +https://bsvblockchain.org/network-access-rules. + +"Software" means the software the subject of this license, including any/all +intellectual property rights therein and associated documentation files. + +BSV Association grants permission, free of charge and on a non-exclusive basis +to any person obtaining a copy of the Software to deal in the Software, including +without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to and conditioned upon the following +conditions: + +1 - The text "© BSV Association", and this license shall be included in all +copies or substantial portions of the Software. + +2 - The Software, and any software that is derived from the Software or parts +thereof, may only be used exclusively on the BSV Blockchain. + +For the avoidance of doubt, this license is granted subject to and conditioned +upon your compliance with these terms only and is limited to uses on the BSV +Blockchain. Any exercise of rights not compliant with these terms including +use not for the BSV Blockchain is deemed outside the scope of the license. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES REGARDING ENTITLEMENT, +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS THEREOF BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/packages/helpers/air-gap/README.md b/packages/helpers/air-gap/README.md new file mode 100644 index 000000000..230f8f747 --- /dev/null +++ b/packages/helpers/air-gap/README.md @@ -0,0 +1,112 @@ +# @bsv/air-gap + +[![npm version](https://img.shields.io/npm/v/@bsv/air-gap)](https://www.npmjs.com/package/@bsv/air-gap) +[![npm downloads](https://img.shields.io/npm/dm/@bsv/air-gap)](https://www.npmjs.com/package/@bsv/air-gap) + +One-directional optical air-gap transport for arbitrary bytes. `@bsv/air-gap` turns a byte array into an endless, deterministic sequence of fountain-coded parts to display as QR codes, and reassembles the original bytes from a camera feed with no back-channel of any kind. Because the parts are fountain-coded rather than numbered chunks, any `K + ε` distinct parts reconstruct the message: a receiver that misses frames — and at several frames per second it will — simply keeps watching instead of waiting for one specific index to come round again. The package is payload-agnostic and has zero runtime dependencies; what the bytes mean, how they are rendered, and how fast they are shown are all decisions for the layer above. + +## Install + +```bash +npm install @bsv/air-gap +``` + +No peer dependencies. Works in browsers and in Node 22 or newer. + +## Quick start + +### Sending: display parts + +```ts +import { AirGapEncoder } from '@bsv/air-gap' + +const encoder = new AirGapEncoder(payloadBytes) + +// The encoder holds no cursor — you own the sequence number and the cadence. +let seq = 0 +const timer = setInterval(() => { + renderQrCode(encoder.partAt(seq++)) // e.g. 5 frames per second +}, 200) +``` + +`partAt(seq)` is a pure function of `(message, blockBytes, seq)`, so `seq` may grow without bound and the same part can be re-rendered as often as needed. A single-block message needs no animation at all: display `partAt(0)` and never advance `seq`. + +### Receiving: accept scans + +```ts +import { AirGapDecoder } from '@bsv/air-gap' + +const decoder = new AirGapDecoder() + +onBarcodeScan(text => { + const { ok, done, have, total } = decoder.accept(text) + if (ok) showProgress(have, total) + if (!done) return + + const payload = decoder.message() + if (payload) finish(payload) // verified bytes, exactly as they were sent +}) +``` + +`accept` never throws and never emits a partial message — a camera hands it stray reads, other people's QR codes and half-decoded frames, and every one of those is an ordinary `{ ok: false }` that changes nothing. `message()` returns the payload only once every block is recovered *and* the CRC-32 matches; on a mismatch it discards the assembly and resets itself, so a still-looping sender refills it without the application having to manage a retry. + +## Wire format + +A part is the prefix followed by unpadded base64url of a fixed 14-byte big-endian header and exactly one block: + +``` +air-gap: + base64url( seq ‖ K ‖ msgLen ‖ crc32 ‖ block ) +``` + +| Field | Size | Meaning | +|-------|------|---------| +| `seq` | u32 | Part sequence number, unbounded. `seq < K` is source block `seq` verbatim (the systematic prefix). | +| `K` | u16 | Source block count, `ceil(msgLen / blockBytes)`. | +| `msgLen` | u32 | Length of the whole payload in bytes. | +| `crc32` | u32 | IEEE CRC-32 of the whole original payload, and of nothing else. | +| `block` | `blockBytes` | One source block, or an XOR mix of several. The last source block is zero-padded. | + +The block size is deliberately **not** on the wire: the decoder infers it from the payload length, which keeps every part exactly the same size and lets each application pick its own symbol density. Three consequences worth knowing: + +- **Session identity** is `(K, msgLen, crc32)`. A part with different values is a different message, and adopting it resets the decoder. +- **The block size is pinned** by the first part accepted into a session. Later parts that disagree are rejected, which is what stops two senders — or one mangled frame — from being assembled together. +- **Determinism is the contract.** The mixes are chosen by an xorshift32 RNG seeded from `seq` with an ideal-soliton degree, so a decoder rebuilds each part's block set from `seq` alone. `tests/vectors.test.ts` freezes the exact strings any implementation must reproduce. + +## API + +| Export | Purpose | +|--------|---------| +| `AirGapEncoder` | `new AirGapEncoder(message, blockBytes?)`; `partAt(seq)` renders a part string. Read-only `blockCount`, `blockBytes`, `messageLength`. | +| `AirGapDecoder` | `accept(text)` feeds one scan and returns `{ ok, done, have, total }`; `message()` returns the verified payload or `null`; `reset()` abandons the current scan. | +| `AirGapProgress` | Type of what `accept` returns. | +| `crc32(bytes)` | IEEE CRC-32 as an unsigned 32-bit number. | +| `isAirGapPart(text)` | Cheap prefix test, for routing camera reads before decoding them. | +| `estimatePartCharLength(blockBytes?)` | Exact character length of every part for a block size, for sizing against a QR version's capacity. | +| `AirGapError` | Thrown by the encoder on a message or configuration it cannot send. The decoder never throws. | +| `AIR_GAP_PREFIX` | `'air-gap:'` | +| `DEFAULT_BLOCK_BYTES` | `1200` | +| `MAX_MESSAGE_BYTES` | `65536` | + +## Defaults and tunables + +`DEFAULT_BLOCK_BYTES` is 1,200, which renders as a 1,627-character part — inside a version-40 QR symbol with margin for a camera that is not square-on to the screen. Lower it for smaller, more forgiving symbols at the cost of more parts; raise it only if the receiving camera really can resolve the density. + +`MAX_MESSAGE_BYTES` is 65,536. At five parts per second and the default block size that is already 15 to 30 seconds of two people holding phones together, which is the practical limit of the medium; a larger payload is a sign the layer above should send a reference instead of the bytes. + +Display cadence is not this package's concern and is not configurable here — no frame interval is exported. The application owns its own animation loop. + +## Prior art + +Not wire-compatible with any of these; listed because they solve the same problem: + +- **BRC-225 TKQR1** — fixed-order indexed chunks; a peer alternative with no shared framing. +- **BC-UR** (`ur:`) — fountain-coded QR for crypto air-gaps; different framing and coding. +- **bsv-browser `fountain.ts`** (`bsvpayf2:`) — the direct algorithm ancestor of this package, from which the coding is ported bit-for-bit. This is its payload-agnostic evolution; the legacy prefix is not accepted. + +## Non-goals + +QR rendering, camera and barcode-scanner integration, animation timing, compression, encryption, authentication beyond the payload CRC, and payload schemas of any kind — payments, signing requests, BRC-100 blobs — all live above this transport, not in it. + +## License + +Open BSV License — see [LICENSE.txt](./LICENSE.txt). diff --git a/packages/helpers/air-gap/jest.config.cjs b/packages/helpers/air-gap/jest.config.cjs new file mode 100644 index 000000000..8cdf5ab85 --- /dev/null +++ b/packages/helpers/air-gap/jest.config.cjs @@ -0,0 +1,24 @@ +/** @type {import('jest').Config} */ +module.exports = { + bail: 1, + moduleFileExtensions: ['ts', 'js'], + modulePathIgnorePatterns: ['out/src', 'out/test', 'dist'], + rootDir: '.', + roots: [''], + testEnvironment: 'node', + testMatch: ['**/?(*.)+(test).[tj]s'], + testRegex: [], + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + rootDir: '.', + tsconfig: { + module: 'commonjs', + moduleResolution: 'bundler' + } + } + ] + }, + verbose: true +} diff --git a/packages/helpers/air-gap/package.json b/packages/helpers/air-gap/package.json new file mode 100644 index 000000000..ca65112aa --- /dev/null +++ b/packages/helpers/air-gap/package.json @@ -0,0 +1,78 @@ +{ + "name": "@bsv/air-gap", + "version": "0.1.0", + "sideEffects": false, + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public" + }, + "description": "One-directional optical air-gap transport: fountain-coded QR parts for arbitrary bytes", + "author": "Peer-to-peer Privacy Systems Research, LLC & BSV Association", + "license": "SEE LICENSE IN LICENSE.txt", + "type": "commonjs", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "files": [ + "dist", + "README.md", + "LICENSE.txt" + ], + "directories": { + "test": "tests" + }, + "scripts": { + "test": "jest", + "test:property": "jest --runInBand tests/airGapCodec.property.test.ts", + "test:coverage": "jest --coverage", + "test:watch": "jest --watch", + "build": "tsdown src/index.ts --format cjs,esm --dts --sourcemap --clean --out-dir dist --tsconfig tsconfig.build.json", + "format:check": "pnpm --workspace-root exec prettier --check \"packages/helpers/air-gap/{src,tests}/**/*.ts\" \"packages/helpers/air-gap/*.{cjs,json,ts}\"", + "lint": "oxlint src tests --deny-warnings", + "pack:check": "node ../../../scripts/check-package-artifact.mjs . --exports AIR_GAP_PREFIX,DEFAULT_BLOCK_BYTES,MAX_MESSAGE_BYTES,AirGapError,crc32,AirGapEncoder,AirGapDecoder,isAirGapPart,estimatePartCharLength", + "typecheck": "tsc --project tsconfig.typecheck.json" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/bsv-blockchain/ts-stack.git", + "directory": "packages/helpers/air-gap" + }, + "bugs": { + "url": "https://github.com/bsv-blockchain/ts-stack/issues" + }, + "homepage": "https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/air-gap#readme", + "devDependencies": { + "@types/jest": "^30.0.0", + "@types/node": "^26.1.2", + "@typescript/native": "npm:typescript@7.0.2", + "fast-check": "^4.9.0", + "jest": "^30.4.2", + "oxlint": "^1.76.0", + "ts-jest": "^29.4.12", + "tsdown": "0.22.14", + "typescript": "npm:@typescript/typescript6@6.0.2" + }, + "keywords": [ + "bsv", + "air-gap", + "qr", + "fountain-code", + "luby", + "optical", + "transport" + ] +} diff --git a/packages/helpers/air-gap/src/base64url.ts b/packages/helpers/air-gap/src/base64url.ts new file mode 100644 index 000000000..825ff56d5 --- /dev/null +++ b/packages/helpers/air-gap/src/base64url.ts @@ -0,0 +1,50 @@ +/** + * Unpadded base64url, kept local so this package has no runtime dependencies. + * + * base64url rather than base64 because a part string ends up in QR alphanumeric + * mode, URLs and log lines, and `+` / `/` / `=` are hostile in all three. + * Encoding runs through `globalThis.btoa` / `atob`, which exist in browsers and + * in Node 22+, so the same code path serves both. + */ + +/** + * `String.fromCharCode` is variadic and each argument occupies a stack slot, so + * a whole 64 KiB buffer in one call can blow the argument limit. Chunking keeps + * the fast native path without that risk. + */ +const CHUNK = 0x8000 + +/** `true` for a well-formed unpadded base64url body (including the empty string). */ +const BASE64URL = /^[\w-]*$/ + +/** Unpadded base64url text for `bytes`. */ +export function toB64url(bytes: Uint8Array): string { + let binary = '' + for (let i = 0; i < bytes.length; i += CHUNK) { + // Every byte is below 0x100, so a code point is a single code unit here. + binary += String.fromCodePoint(...bytes.subarray(i, i + CHUNK)) + } + return globalThis.btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '') +} + +/** + * The bytes behind unpadded base64url `text`. + * + * Rejects anything outside the base64url alphabet, and any length that cannot + * be a base64 body, before handing the string to `atob` — runtimes disagree on + * how lenient `atob` is, and the decoder's soft-reject contract needs the + * answer to be the same everywhere. + * + * @throws {Error} when `text` is not valid unpadded base64url. + */ +export function fromB64url(text: string): Uint8Array { + if (!BASE64URL.test(text)) throw new Error('invalid base64url') + // A trailing group of one character carries 6 bits and cannot encode a byte. + if (text.length % 4 === 1) throw new Error('invalid base64url length') + const padded = text.replaceAll('-', '+').replaceAll('_', '/') + const binary = globalThis.atob(padded + '='.repeat((4 - (padded.length % 4)) % 4)) + const bytes = new Uint8Array(binary.length) + // `atob` yields one code unit below 0x100 per byte, so this cannot truncate. + for (let i = 0; i < binary.length; i++) bytes[i] = binary.codePointAt(i)! + return bytes +} diff --git a/packages/helpers/air-gap/src/coding.ts b/packages/helpers/air-gap/src/coding.ts new file mode 100644 index 000000000..f927bf90b --- /dev/null +++ b/packages/helpers/air-gap/src/coding.ts @@ -0,0 +1,74 @@ +/** + * The deterministic part-to-blocks mapping at the heart of the fountain. + * + * DETERMINISM IS THE CONTRACT. A part carries its `seq` and nothing about which + * source blocks it mixes; the decoder reconstructs that set by running the same + * RNG over the same seed. Change a constant here and every frozen conformance + * vector — and every peer implementation — stops interoperating. Treat this + * file as frozen wire format, not as code to improve. + */ + +/** + * xorshift32. Never returns 0 and is never seeded with 0. + * + * Chosen for reproducibility across runtimes and languages rather than for + * statistical quality: 32-bit state, three shifts, no library, no floats. + */ +function makeRng(seed: number): () => number { + let x = seed >>> 0 + if (x === 0) x = 0x6d2b79f5 + return () => { + x ^= x << 13 + x >>>= 0 + x ^= x >>> 17 + x ^= x << 5 + x >>>= 0 + return x + } +} + +/** + * The source-block indices XORed into part `seq`. + * + * Only meaningful for `seq >= k`; below `k` the part *is* block `seq` (the + * systematic prefix, so one clean camera cycle decodes with zero overhead). + * + * The degree follows the ideal soliton distribution — 1 with probability 1/K, + * otherwise `d` with probability 1/(d(d-1)) via the `ceil(1/u)` inverse-CDF + * trick — which is what makes any K+ε distinct parts enough to peel out all K + * blocks. Indices come from a partial Fisher–Yates shuffle, so they are + * distinct without a rejection loop. + * + * @param seq - Part sequence number. + * @param k - Source block count. + */ +export function blocksForPart(seq: number, k: number): number[] { + const rng = makeRng((seq * 0x9e3779b1) >>> 0) + // (0,1] for the degree draw — the +1 keeps 1/u finite. + const open01 = () => ((rng() >>> 9) + 1) / 2 ** 23 + // [0,1) for index draws — floor stays in range. + const half01 = () => (rng() >>> 9) / 2 ** 23 + let degree: number + if (k === 1) degree = 1 + else if (open01() <= 1 / k) degree = 1 + else degree = Math.min(k, Math.ceil(1 / open01())) + const pool = Array.from({ length: k }, (_, i) => i) + for (let i = 0; i < degree; i++) { + const j = i + Math.floor(half01() * (k - i)) + const t = pool[i] + pool[i] = pool[j] + pool[j] = t + } + return pool.slice(0, degree) +} + +/** + * XOR `source` into `target`, in place, over `target.length` bytes. + * + * Both sides are always one block long — the encoder pads the last source + * block and the decoder pins the session's block size — so the loop needs no + * length reconciliation. + */ +export function xorInto(target: Uint8Array, source: Uint8Array): void { + for (let i = 0; i < target.length; i++) target[i] ^= source[i] +} diff --git a/packages/helpers/air-gap/src/constants.ts b/packages/helpers/air-gap/src/constants.ts new file mode 100644 index 000000000..2760c89b6 --- /dev/null +++ b/packages/helpers/air-gap/src/constants.ts @@ -0,0 +1,46 @@ +/** + * Wire constants for the air-gap transport. + * + * Everything here is part of the wire contract except `DEFAULT_BLOCK_BYTES`, + * which is only a sensible starting point: the block size is *not* carried in + * the header, so each application is free to trade symbol density against part + * count. Deliberately absent is any notion of display cadence — how fast parts + * are rendered is the application's business, not the transport's. + */ + +/** ASCII prefix every wire part starts with. */ +export const AIR_GAP_PREFIX = 'air-gap:' + +/** + * Default source-block size in bytes. + * + * A 1,200-byte block yields a 1,214-byte part, which is 1,619 unpadded + * base64url characters plus the 8-character prefix — comfortably inside the + * alphanumeric capacity of a version-40 QR symbol at low error correction, + * with margin for a scanner that is not looking at the screen straight on. + */ +export const DEFAULT_BLOCK_BYTES = 1200 + +/** + * Sanity ceiling on a whole message. + * + * At five parts per second with 1,200-byte blocks, 64 KiB is roughly 55 source + * blocks — some 15 to 30 seconds of two people holding phones together, which + * is already the practical limit of the medium. A larger payload means the + * layer above should be sending a reference rather than the bytes themselves. + */ +export const MAX_MESSAGE_BYTES = 65536 + +/** Fixed header size in bytes: `seq` u32 ‖ `K` u16 ‖ `msgLen` u32 ‖ `crc32` u32. */ +export const HEADER_BYTES = 14 + +/** + * Largest source-block count the header can express, since `K` is a u16. + * + * Reachable only with a pathologically small `blockBytes`; the encoder rejects + * such a configuration rather than silently truncating `K` on the wire. + */ +export const MAX_BLOCK_COUNT = 0xffff + +/** Exclusive upper bound on `seq`, which the header carries as a u32. */ +export const MAX_SEQ_EXCLUSIVE = 0x1_0000_0000 diff --git a/packages/helpers/air-gap/src/crc32.ts b/packages/helpers/air-gap/src/crc32.ts new file mode 100644 index 000000000..7358c56f8 --- /dev/null +++ b/packages/helpers/air-gap/src/crc32.ts @@ -0,0 +1,34 @@ +/** + * CRC-32 (IEEE 802.3), the standard table-driven implementation. + * + * This is an *integrity* check on the reassembled payload, not a cryptographic + * one: it catches the failure this transport actually has — a camera read that + * decoded to the wrong bits, or two encoders whose parts got interleaved — for + * four bytes on every frame. Authenticating the payload is the caller's job, + * and belongs in the payload. + */ + +/** Reversed polynomial 0xedb88320, one entry per possible low byte. */ +const CRC_TABLE = (() => { + const table = new Uint32Array(256) + for (let n = 0; n < 256; n++) { + let c = n + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + table[n] = c >>> 0 + } + return table +})() + +/** + * The IEEE CRC-32 of `bytes`, as an unsigned 32-bit number. + * + * @example + * ```ts + * crc32(new TextEncoder().encode('123456789')) // 0xcbf43926 + * ``` + */ +export function crc32(bytes: Uint8Array): number { + let c = 0xffffffff + for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8) + return (c ^ 0xffffffff) >>> 0 +} diff --git a/packages/helpers/air-gap/src/decoder.ts b/packages/helpers/air-gap/src/decoder.ts new file mode 100644 index 000000000..282d0306d --- /dev/null +++ b/packages/helpers/air-gap/src/decoder.ts @@ -0,0 +1,231 @@ +import { fromB64url } from './base64url' +import { blocksForPart, xorInto } from './coding' +import { AIR_GAP_PREFIX, HEADER_BYTES, MAX_MESSAGE_BYTES } from './constants' +import { crc32 } from './crc32' + +/** What one call to {@link AirGapDecoder.accept} learned. */ +export interface AirGapProgress { + /** `false` for a read that was not a usable part of the current message. */ + ok: boolean + /** All source blocks are recovered; call {@link AirGapDecoder.message}. */ + done: boolean + /** Source blocks recovered so far. */ + have: number + /** Source blocks in the message being received, or 0 before the first part. */ + total: number +} + +/** A part that still mixes more than one unrecovered source block. */ +interface PendingPart { + indices: Set + payload: Uint8Array +} + +/** + * Reassembles a message from a stream of scanned parts. + * + * NEVER THROWS. A camera hands this whatever the barcode library thought it + * saw: other people's QR codes, half-decoded frames, parts of a message the + * user already walked away from. Every one of those is an ordinary + * `{ ok: false }` read that changes nothing, because the alternative — an + * exception inside a video frame callback — permanently wedges the scanner. + * + * Nor does it ever emit a partial or unverified message: {@link message} + * returns the payload only once all blocks are recovered *and* the CRC matches. + * + * @example + * ```ts + * const decoder = new AirGapDecoder() + * onScan(text => { + * if (decoder.accept(text).done) { + * const payload = decoder.message() + * if (payload) finish(payload) + * } + * }) + * ``` + */ +export class AirGapDecoder { + /** `${K}:${msgLen}:${crc}` — identity of the message being received. */ + private key = '' + private total = 0 + private msgLen = 0 + private crc = 0 + /** + * Payload length pinned by the first part accepted into the current session + * (0 = unpinned). + * + * The session key deliberately excludes the block size, so two honest + * encoders configured with different `blockBytes` — or one part whose payload + * was padded or truncated with its header untouched — can still satisfy the + * `ceil(msgLen / len) === K` agreement check while disagreeing with every + * part already accepted. Mixing those produces blocks of two different + * lengths, and assembly would then size its buffer from one and overrun on + * the other. Pinning turns that into an ordinary rejected read. + */ + private blockBytes = 0 + private seen = new Set() + private solved: (Uint8Array | null)[] = [] + private solvedCount = 0 + private pending: PendingPart[] = [] + + /** + * Forget everything and wait for a fresh message. + * + * Not needed for correctness — a part from a different message resets the + * decoder on its own — but useful when the UI abandons a scan. + */ + reset(): void { + this.startSession('', 0, 0, 0) + } + + private startSession(key: string, total: number, msgLen: number, crc: number): void { + this.key = key + this.total = total + this.msgLen = msgLen + this.crc = crc + this.blockBytes = 0 + this.seen = new Set() + this.solved = Array.from({ length: total }, () => null) + this.solvedCount = 0 + this.pending = [] + } + + /** Current progress, unchanged, for a read that could not be used. */ + private rejected(): AirGapProgress { + return { ok: false, done: false, have: this.solvedCount, total: this.total } + } + + /** Current progress after a read that was used (or was a known duplicate). */ + private accepted(): AirGapProgress { + return { + ok: true, + done: this.solvedCount === this.total && this.total > 0, + have: this.solvedCount, + total: this.total + } + } + + /** + * Feed one scanned string. + * + * A part belonging to a different message — different `(K, msgLen, crc32)` — + * silently replaces the current session, which is also how the decoder + * recovers after {@link message} discards a corrupt assembly: the sender is + * still looping, so it simply refills. + */ + accept(text: string): AirGapProgress { + if (typeof text !== 'string' || !text.startsWith(AIR_GAP_PREFIX)) return this.rejected() + let bytes: Uint8Array + try { + bytes = fromB64url(text.slice(AIR_GAP_PREFIX.length)) + } catch { + return this.rejected() + } + if (bytes.length <= HEADER_BYTES) return this.rejected() + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const seq = view.getUint32(0) + const total = view.getUint16(4) + const msgLen = view.getUint32(6) + const crc = view.getUint32(10) + const payload = bytes.subarray(HEADER_BYTES) + if (total === 0 || msgLen === 0 || msgLen > MAX_MESSAGE_BYTES) return this.rejected() + // Block size, msgLen and K must agree, or the sender and this decoder are + // not talking about the same message shape. + if (Math.ceil(msgLen / payload.length) !== total) return this.rejected() + + const key = `${total}:${msgLen}:${crc}` + if (key !== this.key) this.startSession(key, total, msgLen, crc) + + // The agreement check above admits a *range* of payload lengths for a given + // (msgLen, K); only the pin can tell two block sizes apart. + if (this.blockBytes === 0) this.blockBytes = payload.length + else if (payload.length !== this.blockBytes) return this.rejected() + + if (this.seen.has(seq)) return this.accepted() + this.seen.add(seq) + + const indices = seq < total ? new Set([seq]) : new Set(blocksForPart(seq, total)) + this.ingest({ indices, payload }) + return this.accepted() + } + + /** + * Peeling: reduce a part by what is already known, take it as a solution once + * one unknown block remains, then cascade — one solve can unlock a chain of + * previously over-determined parts. + */ + private ingest(part: PendingPart): void { + this.reduce(part) + if (part.indices.size === 0) return // pure redundancy + if (part.indices.size > 1) { + this.pending.push(part) + return + } + this.solve(part) + // Reducing each pending part inside the loop means an earlier solve in the + // same pass is already accounted for by the time a later part is examined. + let progressed = true + while (progressed) { + progressed = false + const still: PendingPart[] = [] + for (const p of this.pending) { + this.reduce(p) + if (p.indices.size === 1) { + this.solve(p) + progressed = true + } else if (p.indices.size > 1) still.push(p) + } + this.pending = still + } + } + + /** + * XOR out every block of `part` that is already solved. + * + * Deleting the entry the iterator is currently on is well-defined for a Set, + * so this needs no snapshot of the index list. + */ + private reduce(part: PendingPart): void { + for (const index of part.indices) { + const known = this.solved[index] + if (known) { + xorInto(part.payload, known) + part.indices.delete(index) + } + } + } + + /** + * Record a degree-1 part as its one remaining block. + * + * Only ever called on a part {@link reduce} has just left with exactly one + * index, and reduce removes every index that is already solved — so the block + * recorded here is always new and `solvedCount` cannot double-count. + */ + private solve(part: PendingPart): void { + const [index] = part.indices + this.solved[index] = part.payload + this.solvedCount++ + } + + /** + * The assembled message once {@link accept} reported `done`, or `null`. + * + * `null` means either "not finished yet" or "finished and the CRC did not + * match" — in the latter case the assembly is discarded and the decoder + * resets itself, so a still-looping sender refills it from scratch. Callers + * never see unverified bytes, and never have to handle a retry themselves. + */ + message(): Uint8Array | null { + if (this.total === 0 || this.solvedCount !== this.total || this.blockBytes === 0) return null + const out = new Uint8Array(this.total * this.blockBytes) + for (let i = 0; i < this.total; i++) out.set(this.solved[i]!, i * this.blockBytes) + const trimmed = out.subarray(0, this.msgLen) + if (crc32(trimmed) !== this.crc) { + this.reset() + return null + } + // Copy so the result does not retain the padded assembly buffer. + return trimmed.slice() + } +} diff --git a/packages/helpers/air-gap/src/encoder.ts b/packages/helpers/air-gap/src/encoder.ts new file mode 100644 index 000000000..943d2ed9e --- /dev/null +++ b/packages/helpers/air-gap/src/encoder.ts @@ -0,0 +1,108 @@ +import { toB64url } from './base64url' +import { blocksForPart, xorInto } from './coding' +import { + AIR_GAP_PREFIX, + DEFAULT_BLOCK_BYTES, + HEADER_BYTES, + MAX_BLOCK_COUNT, + MAX_MESSAGE_BYTES, + MAX_SEQ_EXCLUSIVE +} from './constants' +import { crc32 } from './crc32' +import { AirGapError } from './errors' + +/** + * Turns one message into an endless, deterministic sequence of wire parts. + * + * An encoder is immutable and holds no cursor: `partAt(seq)` is a pure function + * of `(message, blockBytes, seq)`. The caller owns the sequence number and the + * cadence, which is what lets a static single-part display, a 5 fps animation + * and a frozen conformance vector all be the same code path. + * + * @example + * ```ts + * const encoder = new AirGapEncoder(payload) + * let seq = 0 + * setInterval(() => render(encoder.partAt(seq++)), 200) // the app owns timing + * ``` + */ +export class AirGapEncoder { + /** Views into one zero-padded backing buffer, `blockBytes` each. */ + private readonly blocks: readonly Uint8Array[] + private readonly crc: number + + /** Source block count, `ceil(messageLength / blockBytes)`. Sent as `K`. */ + readonly blockCount: number + /** Bytes per source block, and therefore per part payload. */ + readonly blockBytes: number + /** Length of the original message in bytes. */ + readonly messageLength: number + + /** + * @param message - The bytes to transmit. Copied, so later mutation by the + * caller cannot break the determinism contract. + * @param blockBytes - Payload bytes per part. Not carried on the wire; pick + * it from the symbol capacity the receiving camera can actually resolve. + * @throws {AirGapError} when `message` is empty or larger than + * {@link MAX_MESSAGE_BYTES}, or when `blockBytes` is not a positive integer + * or would need more than 65,535 source blocks. + */ + constructor(message: Uint8Array, blockBytes: number = DEFAULT_BLOCK_BYTES) { + if (message.length === 0) throw new AirGapError('cannot encode an empty message') + if (message.length > MAX_MESSAGE_BYTES) { + throw new AirGapError( + `message of ${message.length} bytes exceeds the ${MAX_MESSAGE_BYTES}-byte maximum` + ) + } + if (!Number.isInteger(blockBytes) || blockBytes < 1) { + throw new AirGapError(`blockBytes must be a positive integer, received ${blockBytes}`) + } + const blockCount = Math.ceil(message.length / blockBytes) + if (blockCount > MAX_BLOCK_COUNT) { + throw new AirGapError( + `blockBytes of ${blockBytes} needs ${blockCount} blocks, over the ${MAX_BLOCK_COUNT} the header can carry` + ) + } + this.messageLength = message.length + this.blockBytes = blockBytes + this.blockCount = blockCount + this.crc = crc32(message) + // One allocation: `set` copies the message in and leaves the tail of the + // last block zeroed, which is exactly the padding the wire format wants. + const padded = new Uint8Array(blockCount * blockBytes) + padded.set(message) + this.blocks = Array.from({ length: blockCount }, (_, i) => + padded.subarray(i * blockBytes, (i + 1) * blockBytes) + ) + } + + /** + * Part `seq`, ready to render. + * + * `seq < blockCount` returns source block `seq` verbatim — the systematic + * prefix, so an unlucky-free receiver finishes in exactly `blockCount` reads. + * Past that, parts are XOR mixes and are interchangeable: any `blockCount + ε` + * distinct parts reconstruct the message, so `seq` may grow without bound and + * a missed frame costs almost nothing. + * + * @throws {AirGapError} when `seq` is not a u32. + */ + partAt(seq: number): string { + if (!Number.isInteger(seq) || seq < 0 || seq >= MAX_SEQ_EXCLUSIVE) { + throw new AirGapError(`part sequence must be a 32-bit unsigned integer, received ${seq}`) + } + const k = this.blockCount + const out = new Uint8Array(HEADER_BYTES + this.blockBytes) + // Mix straight into the output buffer; a part is header ‖ payload and the + // payload never needs to exist on its own. + const payload = out.subarray(HEADER_BYTES) + if (seq < k) payload.set(this.blocks[seq]) + else for (const index of blocksForPart(seq, k)) xorInto(payload, this.blocks[index]) + const view = new DataView(out.buffer) + view.setUint32(0, seq) + view.setUint16(4, k) + view.setUint32(6, this.messageLength) + view.setUint32(10, this.crc) + return AIR_GAP_PREFIX + toB64url(out) + } +} diff --git a/packages/helpers/air-gap/src/errors.ts b/packages/helpers/air-gap/src/errors.ts new file mode 100644 index 000000000..a75f19d43 --- /dev/null +++ b/packages/helpers/air-gap/src/errors.ts @@ -0,0 +1,17 @@ +/** + * The single error type this package throws. + * + * Only the *encoder* throws: it is fed by trusted local code, so a bad message + * or a bad block size is a programming error worth surfacing loudly. The + * decoder is fed by a camera and never throws — see {@link AirGapDecoder}. + */ +export class AirGapError extends Error { + override readonly name = 'AirGapError' + + constructor(message: string) { + super(message) + // Keeps `instanceof` working when this package is transpiled down to ES5 + // by a consumer's bundler, which otherwise loses the prototype link. + Object.setPrototypeOf(this, AirGapError.prototype) + } +} diff --git a/packages/helpers/air-gap/src/helpers.ts b/packages/helpers/air-gap/src/helpers.ts new file mode 100644 index 000000000..889697ac2 --- /dev/null +++ b/packages/helpers/air-gap/src/helpers.ts @@ -0,0 +1,36 @@ +import { AIR_GAP_PREFIX, DEFAULT_BLOCK_BYTES, HEADER_BYTES } from './constants' +import { AirGapError } from './errors' + +/** + * Whether `text` looks like an air-gap part. + * + * A prefix test and nothing more — cheap enough to run on every barcode a + * camera reports, so a scanner can route reads without paying for base64 + * decoding. Say nothing about whether the part is well formed; only + * {@link AirGapDecoder.accept} can answer that. + */ +export function isAirGapPart(text: string): boolean { + return typeof text === 'string' && text.startsWith(AIR_GAP_PREFIX) +} + +/** + * Exact character length of every part produced for a given `blockBytes`. + * + * All parts are the same length by construction — the header is fixed and the + * last source block is zero-padded — so this is a sizing aid for choosing + * `blockBytes` against a QR version's alphanumeric capacity *before* building + * an encoder, not an estimate that needs a safety margin. + * + * @throws {AirGapError} when `blockBytes` is not a positive integer. + */ +export function estimatePartCharLength(blockBytes: number = DEFAULT_BLOCK_BYTES): number { + if (!Number.isInteger(blockBytes) || blockBytes < 1) { + throw new AirGapError(`blockBytes must be a positive integer, received ${blockBytes}`) + } + const bytes = HEADER_BYTES + blockBytes + const remainder = bytes % 3 + // Unpadded base64url: 4 characters per whole 3-byte group, then one character + // per 6 bits of the tail (2 for 1 byte, 3 for 2 bytes). + const body = Math.floor(bytes / 3) * 4 + (remainder === 0 ? 0 : remainder + 1) + return AIR_GAP_PREFIX.length + body +} diff --git a/packages/helpers/air-gap/src/index.ts b/packages/helpers/air-gap/src/index.ts new file mode 100644 index 000000000..0878fa464 --- /dev/null +++ b/packages/helpers/air-gap/src/index.ts @@ -0,0 +1,19 @@ +/** + * `@bsv/air-gap` — one-directional optical air-gap transport. + * + * Encodes arbitrary bytes as a deterministic, endless sequence of fountain-coded + * parts for display as QR codes (or any optical channel), and reassembles them + * from a camera feed with no back-channel of any kind. + * + * The transport is payload-agnostic and stops at the byte array: rendering, + * scanning, display cadence and payload semantics all belong to the layer above. + * + * @packageDocumentation + */ + +export { AIR_GAP_PREFIX, DEFAULT_BLOCK_BYTES, MAX_MESSAGE_BYTES } from './constants' +export { AirGapError } from './errors' +export { crc32 } from './crc32' +export { AirGapEncoder } from './encoder' +export { AirGapDecoder, type AirGapProgress } from './decoder' +export { estimatePartCharLength, isAirGapPart } from './helpers' diff --git a/packages/helpers/air-gap/tests/airGapCodec.property.test.ts b/packages/helpers/air-gap/tests/airGapCodec.property.test.ts new file mode 100644 index 000000000..13d3411d7 --- /dev/null +++ b/packages/helpers/air-gap/tests/airGapCodec.property.test.ts @@ -0,0 +1,158 @@ +/** + * Property tests for the wire boundary. + * + * The decoder's input is whatever a barcode library thought it saw through a + * camera, which makes it the one surface in this package that is genuinely + * adversarial: arbitrary strings, arbitrary bit flips, arbitrary frame loss. + * These properties pin the two guarantees that matter there — total functions + * that never throw, and bytes that are either exactly what was sent or nothing + * at all — over inputs no hand-written fixture would think to try. + */ +import fc from 'fast-check' + +import { AIR_GAP_PREFIX, MAX_MESSAGE_BYTES } from '../src/constants' +import { AirGapDecoder } from '../src/decoder' +import { AirGapEncoder } from '../src/encoder' +import { estimatePartCharLength } from '../src/helpers' +import { partBytes, toPart } from './helpers' + +const MIN_PROPERTY_RUNS = 300 +const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) +const requestedSeed = Number.parseInt(process.env.FAST_CHECK_SEED ?? '', 10) +const replayPath = process.env.FAST_CHECK_PATH + +fc.configureGlobal({ + numRuns: Number.isSafeInteger(requestedRuns) + ? Math.max(MIN_PROPERTY_RUNS, requestedRuns) + : MIN_PROPERTY_RUNS, + ...(Number.isSafeInteger(requestedSeed) ? { seed: requestedSeed } : {}), + ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) +}) + +/** + * Payloads big enough to span several blocks, small enough to stay quick. + * + * The explicit size bias matters: fast-check's default keeps arrays around a + * dozen bytes, which would leave every multi-block path untested. + */ +const payloadUpTo = (maxLength: number) => + fc.uint8Array({ minLength: 1, maxLength, size: 'medium' }) +const payload = payloadUpTo(2048) +/** Block sizes from "absurdly small" up to the default. */ +const blockBytes = fc.integer({ min: 1, max: 1200 }) + +describe('air-gap wire properties', () => { + it('round-trips arbitrary bytes through one systematic cycle', () => { + fc.assert( + fc.property(payload, blockBytes, (bytes, block) => { + const enc = new AirGapEncoder(bytes, block) + const dec = new AirGapDecoder() + for (let seq = 0; seq < enc.blockCount; seq++) { + const progress = dec.accept(enc.partAt(seq)) + expect(progress.ok).toBe(true) + expect(progress.total).toBe(enc.blockCount) + expect(progress.have).toBe(seq + 1) + } + expect(Array.from(dec.message()!)).toEqual(Array.from(bytes)) + }) + ) + }) + + it('recovers arbitrary bytes through arbitrary frame loss', () => { + fc.assert( + fc.property( + payloadUpTo(1024), + fc.integer({ min: 32, max: 600 }), + fc.array(fc.boolean(), { minLength: 8, maxLength: 64 }), + (bytes, block, mask) => { + const enc = new AirGapEncoder(bytes, block) + const dec = new AirGapDecoder() + // A mask that drops every frame is a camera pointed at the floor. + if (!mask.includes(true)) return + // Otherwise a repeating keep/drop mask stands in for a camera that + // misses frames; the sender keeps looping, so seq climbs regardless + // and the receiver only ever sees the parts the mask lets through. + const budget = 30 * enc.blockCount + 200 + for (let seq = 0, seen = 0; seen < budget; seq++) { + if (!mask[seq % mask.length]) continue + seen++ + if (dec.accept(enc.partAt(seq)).done) break + } + expect(Array.from(dec.message()!)).toEqual(Array.from(bytes)) + } + ), + // Loss sweeps are the slowest property here; the mask space is small. + { numRuns: Math.min(MIN_PROPERTY_RUNS, 120) } + ) + }) + + it('never throws and never emits a message for arbitrary text', () => { + fc.assert( + fc.property( + fc.oneof( + fc.string(), + fc.string({ unit: 'binary' }), + fc.string().map(s => AIR_GAP_PREFIX + s), + fc.uint8Array({ maxLength: 64 }).map(bytes => toPart(bytes)) + ), + text => { + const dec = new AirGapDecoder() + const progress = dec.accept(text) + // Nothing short of a real part can complete a message, and nothing at + // all can make the decoder throw inside a camera callback. + if (!progress.done) expect(dec.message()).toBeNull() + expect(progress.have).toBeLessThanOrEqual(progress.total) + } + ) + ) + }) + + it('emits the original bytes or nothing when a part is corrupted', () => { + fc.assert( + fc.property( + payloadUpTo(512), + fc.integer({ min: 8, max: 128 }), + fc.nat(), + fc.integer({ min: 1, max: 255 }), + (bytes, block, position, delta) => { + const enc = new AirGapEncoder(bytes, block) + const dec = new AirGapDecoder() + // Corrupt one payload byte of the last systematic part, leaving its + // header — and therefore the session key and the crc — untouched. + const raw = partBytes(enc.partAt(enc.blockCount - 1)) + const index = 14 + (position % (raw.length - 14)) + raw[index] = (raw[index] + delta) & 0xff + for (let seq = 0; seq < enc.blockCount - 1; seq++) dec.accept(enc.partAt(seq)) + dec.accept(toPart(raw)) + const out = dec.message() + // Either the flip landed in the zero padding past msgLen and the + // payload is untouched, or the crc catches it and nothing is emitted. + if (out !== null) expect(Array.from(out)).toEqual(Array.from(bytes)) + } + ) + ) + }) + + it('renders every part at exactly the predicted length', () => { + fc.assert( + fc.property(payload, blockBytes, fc.nat(), (bytes, block, seq) => { + const enc = new AirGapEncoder(bytes, block) + const part = enc.partAt(seq) + expect(part.startsWith(AIR_GAP_PREFIX)).toBe(true) + expect(part.length).toBe(estimatePartCharLength(block)) + }) + ) + }) + + it('reports a block count and message length that agree with its input', () => { + fc.assert( + fc.property(payload, blockBytes, (bytes, block) => { + const enc = new AirGapEncoder(bytes, block) + expect(enc.messageLength).toBe(bytes.length) + expect(enc.blockCount).toBe(Math.ceil(bytes.length / block)) + expect(enc.blockCount * enc.blockBytes).toBeGreaterThanOrEqual(bytes.length) + expect(enc.messageLength).toBeLessThanOrEqual(MAX_MESSAGE_BYTES) + }) + ) + }) +}) diff --git a/packages/helpers/air-gap/tests/api.test.ts b/packages/helpers/air-gap/tests/api.test.ts new file mode 100644 index 000000000..4844d449e --- /dev/null +++ b/packages/helpers/air-gap/tests/api.test.ts @@ -0,0 +1,98 @@ +/** + * Guards the published surface. `pack:check` asserts the built artifact exports + * these names; this asserts the source does, so the two cannot drift apart. + */ +import * as airGap from '../src/index' +import { AIR_GAP_PREFIX, DEFAULT_BLOCK_BYTES, MAX_MESSAGE_BYTES } from '../src/constants' +import { AirGapEncoder } from '../src/encoder' +import { AirGapError } from '../src/errors' +import { estimatePartCharLength, isAirGapPart } from '../src/helpers' +import { message } from './helpers' + +describe('public surface', () => { + it('exports exactly the documented names', () => { + expect(Object.keys(airGap).sort()).toEqual([ + 'AIR_GAP_PREFIX', + 'AirGapDecoder', + 'AirGapEncoder', + 'AirGapError', + 'DEFAULT_BLOCK_BYTES', + 'MAX_MESSAGE_BYTES', + 'crc32', + 'estimatePartCharLength', + 'isAirGapPart' + ]) + }) + + it('pins the wire constants', () => { + expect(AIR_GAP_PREFIX).toBe('air-gap:') + expect(DEFAULT_BLOCK_BYTES).toBe(1200) + expect(MAX_MESSAGE_BYTES).toBe(65536) + }) +}) + +describe('AirGapError', () => { + it('is an Error with a stable name', () => { + const error = new AirGapError('boom') + expect(error).toBeInstanceOf(Error) + expect(error).toBeInstanceOf(AirGapError) + expect(error.name).toBe('AirGapError') + expect(error.message).toBe('boom') + expect(String(error)).toBe('AirGapError: boom') + }) +}) + +describe('isAirGapPart', () => { + it('recognises the prefix and nothing else', () => { + expect(isAirGapPart(`${AIR_GAP_PREFIX}AAAA`)).toBe(true) + expect(isAirGapPart(AIR_GAP_PREFIX)).toBe(true) + expect(isAirGapPart('bsvpayf2:AAAA')).toBe(false) + expect(isAirGapPart('tkqr1:AAAA')).toBe(false) + expect(isAirGapPart(` ${AIR_GAP_PREFIX}AAAA`)).toBe(false) + expect(isAirGapPart('')).toBe(false) + }) + + it('tolerates a non-string from an untyped scanner callback', () => { + expect(isAirGapPart(undefined as unknown as string)).toBe(false) + expect(isAirGapPart(null as unknown as string)).toBe(false) + expect(isAirGapPart(7 as unknown as string)).toBe(false) + }) + + it('accepts every part a real encoder produces', () => { + const enc = new AirGapEncoder(message(3700), 1200) + for (const seq of [0, 1, 2, 3, 4, 99]) expect(isAirGapPart(enc.partAt(seq))).toBe(true) + }) +}) + +describe('estimatePartCharLength', () => { + it('matches the real part length exactly', () => { + for (const blockBytes of [1, 2, 3, 4, 8, 37, 1200, 1500, 4096]) { + const enc = new AirGapEncoder(message(8192), blockBytes) + expect(estimatePartCharLength(blockBytes)).toBe(enc.partAt(0).length) + expect(estimatePartCharLength(blockBytes)).toBe(enc.partAt(enc.blockCount + 3).length) + } + }) + + it('defaults to the default block size', () => { + expect(estimatePartCharLength()).toBe(estimatePartCharLength(DEFAULT_BLOCK_BYTES)) + expect(estimatePartCharLength()).toBe(1627) + }) + + it('grows monotonically with the block size', () => { + let previous = 0 + for (let blockBytes = 1; blockBytes < 64; blockBytes++) { + const length = estimatePartCharLength(blockBytes) + expect(length).toBeGreaterThanOrEqual(previous) + previous = length + } + }) + + it('refuses a block size that is not a positive integer', () => { + expect(() => estimatePartCharLength(0)).toThrow(AirGapError) + expect(() => estimatePartCharLength(-8)).toThrow(AirGapError) + expect(() => estimatePartCharLength(1.5)).toThrow(AirGapError) + expect(() => estimatePartCharLength(0)).toThrow( + 'blockBytes must be a positive integer, received 0' + ) + }) +}) diff --git a/packages/helpers/air-gap/tests/base64url.test.ts b/packages/helpers/air-gap/tests/base64url.test.ts new file mode 100644 index 000000000..fc74f3557 --- /dev/null +++ b/packages/helpers/air-gap/tests/base64url.test.ts @@ -0,0 +1,50 @@ +import { fromB64url, toB64url } from '../src/base64url' +import { message } from './helpers' + +describe('base64url', () => { + it('round-trips arbitrary byte lengths', () => { + for (const len of [0, 1, 2, 3, 4, 5, 14, 1214, 40000]) { + const bytes = message(len) + expect(Array.from(fromB64url(toB64url(bytes)))).toEqual(Array.from(bytes)) + } + }) + + it('emits unpadded, URL-safe text only', () => { + const text = toB64url(new Uint8Array([0xfb, 0xff, 0xbf, 0x00])) + expect(text).toMatch(/^[\w-]+$/) + expect(text).not.toContain('=') + expect(text).not.toContain('+') + expect(text).not.toContain('/') + }) + + it('produces the canonical mapping of the base64 alphabet', () => { + // 0xfb 0xff 0xbf covers the two characters that differ from plain base64. + expect(toB64url(new Uint8Array([0xfb, 0xff, 0xbf]))).toBe('-_-_') + expect(Array.from(fromB64url('-_-_'))).toEqual([0xfb, 0xff, 0xbf]) + }) + + it('encodes the empty input as the empty string', () => { + expect(toB64url(new Uint8Array(0))).toBe('') + expect(fromB64url('').length).toBe(0) + }) + + it('rejects characters outside the base64url alphabet', () => { + // `atob` strips ASCII whitespace and accepts standard base64 punctuation, + // so these have to be rejected here or the same wire part would decode on + // one runtime and not another. + for (const text of ['AA AA', 'AA\nAA', 'AAA+', 'AAA/', 'AAAA=', 'AA.AA', 'AAAé']) { + expect(() => fromB64url(text)).toThrow(/invalid base64url/) + } + }) + + it('rejects a length that cannot encode whole bytes', () => { + expect(() => fromB64url('A')).toThrow(/invalid base64url length/) + expect(() => fromB64url('AAAAA')).toThrow(/invalid base64url length/) + }) + + it('accepts every unpadded body length that can encode bytes', () => { + expect(fromB64url('AA').length).toBe(1) + expect(fromB64url('AAA').length).toBe(2) + expect(fromB64url('AAAA').length).toBe(3) + }) +}) diff --git a/packages/helpers/air-gap/tests/crc32.test.ts b/packages/helpers/air-gap/tests/crc32.test.ts new file mode 100644 index 000000000..948b6ca67 --- /dev/null +++ b/packages/helpers/air-gap/tests/crc32.test.ts @@ -0,0 +1,40 @@ +import { crc32 } from '../src/crc32' +import { message } from './helpers' + +describe('crc32', () => { + it('matches the standard check vector', () => { + // The IEEE CRC-32 "check" value: crc32(ascii "123456789") is 0xCBF43926. + expect(crc32(new TextEncoder().encode('123456789'))).toBe(0xcbf43926) + }) + + it('is 0 for no bytes', () => { + expect(crc32(new Uint8Array(0))).toBe(0) + }) + + it('matches known single-byte vectors', () => { + expect(crc32(new Uint8Array([0x00]))).toBe(0xd202ef8d) + expect(crc32(new Uint8Array([0xff]))).toBe(0xff000000) + }) + + it('returns an unsigned 32-bit value even when the high bit is set', () => { + const value = crc32(new Uint8Array([0x00])) + expect(value).toBeGreaterThan(0x7fffffff) + expect(value >>> 0).toBe(value) + }) + + it('is order sensitive', () => { + expect(crc32(new Uint8Array([1, 2]))).not.toBe(crc32(new Uint8Array([2, 1]))) + }) + + it('detects a single flipped bit in a large payload', () => { + const clean = message(4096) + const dirty = message(4096) + dirty[2048] ^= 0x01 + expect(crc32(dirty)).not.toBe(crc32(clean)) + }) + + it('reads only the bytes of a subarray view', () => { + const backing = new Uint8Array([0xaa, 0x31, 0x32, 0x33, 0xbb]) + expect(crc32(backing.subarray(1, 4))).toBe(crc32(new TextEncoder().encode('123'))) + }) +}) diff --git a/packages/helpers/air-gap/tests/decoder.test.ts b/packages/helpers/air-gap/tests/decoder.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6bbde7090b9e336aedd7946e0d53cca2e3e9bdd3 GIT binary patch literal 12914 zcmc&)|8CpJ5$@l4iVYAz6i${U*@+z&E}&d=L6D{h&M9z3n#L5lvRG53!lf+h3*$xazjeg> z;cs8wkIv49Z$@8U{o~{4Ox%h|md-@0+uc`Lupgv}%Kao)ttXLPB*=n;!Nx#(=nNW0 z*&BcUS_Ww-v+CebKi(KVN@|Bo{n!~EWd0=o;AeS9gqa^D9Wj$i`4_Sy=6L#Ql}ptT zc}hdpAK#zKcrG)X?+ARMBR=)8T1N~z;-DiAKkEtddM<<9!;E$} zN%L-=zLI}i#Iae=ydGwmzv_~G(8#ClHpY#5}yfbFbHS z4tFEc%M+1j3s_eO8#oaYKUVlL^{?;)!pzg$k58ag9GVpR!`ANKW8biV^1e4U#_vT> zjO7H%fj>7iBbH7$2|W#D0GVG5F;?)M0@FE zFU^QQaKuP*nIZaK!L*B*p(;A9i@XO%PUMB5#y;GDPY2@&LmBeX^vrMQqnnCxf-roB z+=jhM2V{89;Cna>HUUcJVqQK48Hw;DLE!R(T+9}69?fHEfkVqmMZZ@)s7~60LrD7v zNR*^lmY-wlFs7a9%344QE*{H0eS8}s7+r`7a

!q9t-=3^+LYA=3CJqPUcDt_Vod zkkHN-%@P60li+ZnC4}hx$0S)YND5#iX;wg23zhjGIPj+?#V8H^j=kA(=;vl)7PHI;<6`$D1MYU3Ap4LmLqUhmyS4TW5jI=HB-<-OSKj+P_pWU?n z)Vke1VA!&s>c4ES$F}?^+q>}RExURPBHlyck&Uu=x=2Dfi4ukru}ChHbcwzmu!*DTS`{5Ogl!qbY#rm%F$Ip$46;k5vxCvIhb16 z4vSTnsd4SK!KrkT?`87esLv5;AE)6;V-;e|6zCwcy)*%W;|ywwI$J{+yDx6(+Nnq7 z$iGKPe#}_v^$)4Qv>S>wtZ-1jLc@Wn=&V#UhKK3Hib*8nus-eEenzceU}K#6p}}zH zXva-xT-2Jq8zPk_0+|Ntf%sbo%@eALpeBw-lth~k>@22+<&hfNFY^bgq3oV2)K<(i3}s3s}C;x4C z|Kj=cgCm?^pigixIC_D)8=WE%T(m?sUYsGoLp}%Up=oLJ<}1z_%o8N5wuj;sj|5so zj)|L(T06YULY!qPC;tRPvc(Omk84-M{{baDt7=MhCG--Lq6xb7psVxuC=##Dbd9P@ z8OM7-?ipecBW&PjS%e=HqB3T`%Pd6-JkJoZu507`UFi`QMo^?cvCXXr>;hW6rBmwP ztQ!?SP9tPtb3Z_biCRriDcyOp=2`Cq;wv-?aN+|2V>l#%{P>Mc@ zh0y^@(CesLLCPIs#9A_34ClISloh7B@JtvFEkKnfor9>xmp zW9aK41wvC(9@{jxim0sGWXumlLPY24#R#(}1;2uJ;7wGvilMD^4r?^|k-pr-H=2r8 zfT1E~xUdd}dom4l0d8mqeQPh=DCMr>8XK*q2G*WUx>w-|Ob()iYEO;bO%fR{OH2VE zXc^%^bd?R*Lv1FSe>QwSvd*gW$8|j5McJ6aL-VP14wyi=01u@*U<0(uGC@wR%kC&a zQQArm_Blyl7UMjlYF--!hImo1%P6EfAAuj+0zN?v`Q!io^Iu99vCS1U8G~n1D~-3U zd%VgA+q6{SQm*U{L6%W#vC^WhAEuRM>H2OBV)04^P|->1gI66`LCAd^bISx0g7XEK zWohthCfXTlaXhGOwX_#&ln^CBD~w-bloiF=8fvcS;SP|-HL4#~*2c!1wfXBNG_GfD ztDHHbHlyX{N|k8K-z$7v6F{}du854V*6s2IH8y6=9;30qHvbf`z6p>>L5>uEtZOfk z+@TChkVa_~U2cL~LZw^XTAOB#;XY!htuaQ&Savl;tK<=%*+l3TE>;$yTO1iI;c^y9 z!^G$!8933!F@~LeyC770)LsV#@M7E7tW@Us8kNau(IbtK_MV1#a6%AZLGWRH~!n+{ir9iKjt1wnW2<)VCxKi8vv5vGhab1?9 zd0B=9tK~;`6bju4P6fvRYBT@v+O(w9Ka)kP~mfz7^ zv*D^VEguG%FLnM48t<(9O$pP`P&lklp`UJz5Njj1;_zuUrWpZWD!`?3;=NnY(;5V% zLdS7yn7iLFUgx8lv{Ke#1DIw%!~v%Md@d=xrMT+ByXHobCMguzxs_5e55$l&q6Y6c zQ0STku1*w*E>|veyr{{4EpS+1v6}+H&=+q|C_{Qs1R*O~zLZ0C6`lBzgpu{RhysJ% zo;-85v{{(|`GV#uNfmF8-&AiG6uhNA-s}D|IYrjk)k_h59t(1ul2WDJSs+gV?Z)T$I)P$`0xMzRiBjr literal 0 HcmV?d00001 diff --git a/packages/helpers/air-gap/tests/encoder.test.ts b/packages/helpers/air-gap/tests/encoder.test.ts new file mode 100644 index 000000000..4b76a220e --- /dev/null +++ b/packages/helpers/air-gap/tests/encoder.test.ts @@ -0,0 +1,164 @@ +import { + AIR_GAP_PREFIX, + DEFAULT_BLOCK_BYTES, + MAX_BLOCK_COUNT, + MAX_MESSAGE_BYTES +} from '../src/constants' +import { crc32 } from '../src/crc32' +import { AirGapEncoder } from '../src/encoder' +import { AirGapError } from '../src/errors' +import { estimatePartCharLength } from '../src/helpers' +import { message, partBytes, readHeader } from './helpers' + +describe('AirGapEncoder', () => { + it('emits parts of constant size with the right prefix', () => { + const enc = new AirGapEncoder(message(5000), 1200) + const first = enc.partAt(0) + const fountain = enc.partAt(enc.blockCount + 1) + expect(first.startsWith(AIR_GAP_PREFIX)).toBe(true) + expect(fountain.startsWith(AIR_GAP_PREFIX)).toBe(true) + expect(first.length).toBe(fountain.length) + }) + + it('refuses an empty message', () => { + expect(() => new AirGapEncoder(new Uint8Array(0))).toThrow(AirGapError) + expect(() => new AirGapEncoder(new Uint8Array(0))).toThrow(/empty message/) + }) + + it('refuses an oversize message and says by how much', () => { + expect(() => new AirGapEncoder(message(MAX_MESSAGE_BYTES + 1))).toThrow(AirGapError) + expect(() => new AirGapEncoder(message(MAX_MESSAGE_BYTES + 1))).toThrow( + `message of ${MAX_MESSAGE_BYTES + 1} bytes exceeds the ${MAX_MESSAGE_BYTES}-byte maximum` + ) + }) + + it('accepts exactly the maximum message size', () => { + const enc = new AirGapEncoder(message(MAX_MESSAGE_BYTES)) + expect(enc.messageLength).toBe(MAX_MESSAGE_BYTES) + expect(enc.blockCount).toBe(Math.ceil(MAX_MESSAGE_BYTES / DEFAULT_BLOCK_BYTES)) + }) + + it('refuses a block size that is not a positive integer', () => { + expect(() => new AirGapEncoder(message(10), 0)).toThrow(AirGapError) + expect(() => new AirGapEncoder(message(10), -1)).toThrow(AirGapError) + expect(() => new AirGapEncoder(message(10), 1.5)).toThrow(AirGapError) + expect(() => new AirGapEncoder(message(10), Number.NaN)).toThrow(AirGapError) + expect(() => new AirGapEncoder(message(10), 1.5)).toThrow( + 'blockBytes must be a positive integer, received 1.5' + ) + }) + + it('refuses a block size needing more blocks than the u16 K field can carry', () => { + // 65,536 bytes at one byte per block would need 65,536 blocks; K tops out + // at 65,535, which one byte fewer hits exactly. + expect(new AirGapEncoder(message(MAX_BLOCK_COUNT), 1).blockCount).toBe(MAX_BLOCK_COUNT) + expect(() => new AirGapEncoder(message(MAX_MESSAGE_BYTES), 1)).toThrow(AirGapError) + expect(() => new AirGapEncoder(message(MAX_MESSAGE_BYTES), 1)).toThrow( + `blockBytes of 1 needs ${MAX_MESSAGE_BYTES} blocks, over the ${MAX_BLOCK_COUNT} the header can carry` + ) + expect(new AirGapEncoder(message(MAX_MESSAGE_BYTES), 2).blockCount).toBe(32768) + }) + + it('computes blockCount as ceil(len / blockBytes)', () => { + expect(new AirGapEncoder(message(2400), 1200).blockCount).toBe(2) + expect(new AirGapEncoder(message(2401), 1200).blockCount).toBe(3) + expect(new AirGapEncoder(message(37), 1200).blockCount).toBe(1) + expect(new AirGapEncoder(message(1), 1200).blockCount).toBe(1) + }) + + it('exposes its configuration read-only', () => { + const enc = new AirGapEncoder(message(2401), 1200) + expect(enc.blockBytes).toBe(1200) + expect(enc.messageLength).toBe(2401) + expect(enc.blockCount).toBe(3) + }) + + it('defaults blockBytes to DEFAULT_BLOCK_BYTES', () => { + expect(new AirGapEncoder(message(10)).blockBytes).toBe(DEFAULT_BLOCK_BYTES) + }) + + it('writes the documented header for a systematic part', () => { + const msg = message(2401) + const enc = new AirGapEncoder(msg, 1200) + const header = readHeader(enc.partAt(2)) + expect(header).toEqual({ + seq: 2, + k: 3, + msgLen: 2401, + crc: crc32(msg), + payloadLength: 1200 + }) + }) + + it('repeats the same message-wide header fields on fountain parts', () => { + const msg = message(2401) + const enc = new AirGapEncoder(msg, 1200) + const header = readHeader(enc.partAt(9)) + expect(header.seq).toBe(9) + expect(header.k).toBe(3) + expect(header.msgLen).toBe(2401) + expect(header.crc).toBe(crc32(msg)) + }) + + it('is deterministic across instances', () => { + const a = new AirGapEncoder(message(3700), 1200) + const b = new AirGapEncoder(message(3700), 1200) + for (const seq of [0, 1, 3, 4, 17, 4096]) expect(a.partAt(seq)).toBe(b.partAt(seq)) + }) + + it('is deterministic across repeated calls for the same seq', () => { + const enc = new AirGapEncoder(message(3700), 1200) + expect(enc.partAt(11)).toBe(enc.partAt(11)) + }) + + it('zero-pads the final block rather than shortening the part', () => { + const enc = new AirGapEncoder(message(1), 8) + const header = readHeader(enc.partAt(0)) + expect(header.payloadLength).toBe(8) + expect(header.msgLen).toBe(1) + }) + + it('copies the message so later caller mutation cannot change the parts', () => { + const msg = message(64) + const enc = new AirGapEncoder(msg, 32) + const before = enc.partAt(0) + msg[0] ^= 0xff + expect(enc.partAt(0)).toBe(before) + }) + + it('refuses a sequence number outside u32', () => { + const enc = new AirGapEncoder(message(10), 8) + expect(() => enc.partAt(-1)).toThrow(AirGapError) + expect(() => enc.partAt(1.5)).toThrow(AirGapError) + expect(() => enc.partAt(2 ** 32)).toThrow(AirGapError) + expect(() => enc.partAt(Number.NaN)).toThrow(AirGapError) + expect(() => enc.partAt(-1)).toThrow( + 'part sequence must be a 32-bit unsigned integer, received -1' + ) + }) + + it('encodes the largest sequence number the header can carry', () => { + const enc = new AirGapEncoder(message(10), 8) + expect(readHeader(enc.partAt(2 ** 32 - 1)).seq).toBe(2 ** 32 - 1) + }) + + it('produces parts of exactly the estimated character length', () => { + for (const blockBytes of [1, 2, 3, 8, 100, 1200, 1500]) { + const enc = new AirGapEncoder(message(3000), blockBytes) + expect(enc.partAt(0).length).toBe(estimatePartCharLength(blockBytes)) + } + }) + + it('mixes several source blocks into parts past the systematic prefix', () => { + // Some fountain payload must be a genuine XOR of two or more source blocks, + // otherwise the fountain has degenerated into plain chunk cycling. + const enc = new AirGapEncoder(message(4000), 1000) + const payload = (seq: number) => partBytes(enc.partAt(seq)).subarray(14).join(',') + const sources = new Set([0, 1, 2, 3].map(payload)) + const mixes = Array.from({ length: 32 }, (_, i) => payload(4 + i)) + expect(mixes.some(mix => !sources.has(mix))).toBe(true) + // ...and some fountain payload must be a degree-1 repeat of a source block, + // which is what makes late joiners cheap. + expect(mixes.some(mix => sources.has(mix))).toBe(true) + }) +}) diff --git a/packages/helpers/air-gap/tests/helpers.ts b/packages/helpers/air-gap/tests/helpers.ts new file mode 100644 index 000000000..aff041ccf --- /dev/null +++ b/packages/helpers/air-gap/tests/helpers.ts @@ -0,0 +1,95 @@ +import { AIR_GAP_PREFIX } from '../src/constants' +import type { AirGapDecoder } from '../src/decoder' +import type { AirGapEncoder } from '../src/encoder' + +/** Deterministic pseudo-random payload, sized to span several blocks. */ +export function message(len: number): Uint8Array { + const m = new Uint8Array(len) + for (let i = 0; i < len; i++) m[i] = (i * 31 + 7) & 0xff + return m +} + +/** Feeds `seqs` into `decoder` and returns the message the moment it completes. */ +export function drain( + decoder: AirGapDecoder, + encoder: AirGapEncoder, + seqs: Iterable +): Uint8Array | null { + for (const seq of seqs) { + const s = decoder.accept(encoder.partAt(seq)) + if (s.done) return decoder.message() + } + return null +} + +/** Decodes a rendered part back to raw header ‖ payload bytes, for hand-corrupting it. */ +export function partBytes(raw: string): Uint8Array { + const b64 = raw.slice(AIR_GAP_PREFIX.length).replaceAll('-', '+').replaceAll('_', '/') + const binary = globalThis.atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4)) + return Uint8Array.from(binary, c => c.codePointAt(0)!) +} + +/** Re-renders raw header ‖ payload bytes as a wire part string. */ +export function toPart(bytes: Uint8Array): string { + let bin = '' + for (const byte of bytes) bin += String.fromCodePoint(byte) + return ( + AIR_GAP_PREFIX + + globalThis.btoa(bin).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '') + ) +} + +/** The four header fields of a rendered part, read straight off the wire bytes. */ +export function readHeader(raw: string): { + seq: number + k: number + msgLen: number + crc: number + payloadLength: number +} { + const bytes = partBytes(raw) + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + return { + seq: view.getUint32(0), + k: view.getUint16(4), + msgLen: view.getUint32(6), + crc: view.getUint32(10), + payloadLength: bytes.length - 14 + } +} + +/** Builds a wire part from explicit header fields, for negative tests. */ +export function craftPart( + header: { seq: number; k: number; msgLen: number; crc: number }, + payload: Uint8Array +): string { + const bytes = new Uint8Array(14 + payload.length) + const view = new DataView(bytes.buffer) + view.setUint32(0, header.seq) + view.setUint16(4, header.k) + view.setUint32(6, header.msgLen) + view.setUint32(10, header.crc) + bytes.set(payload, 14) + return toPart(bytes) +} + +/** A tiny deterministic LCG, so randomized sweeps stay reproducible. */ +export function lcg(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0 + return state / 2 ** 32 + } +} + +/** `[0, count)` shuffled deterministically by `random`. */ +export function shuffled(count: number, random: () => number): number[] { + const items = Array.from({ length: count }, (_, i) => i) + for (let i = items.length - 1; i > 0; i--) { + const j = Math.floor(random() * (i + 1)) + const t = items[i] + items[i] = items[j] + items[j] = t + } + return items +} diff --git a/packages/helpers/air-gap/tests/roundtrip.test.ts b/packages/helpers/air-gap/tests/roundtrip.test.ts new file mode 100644 index 000000000..5d9c490ee --- /dev/null +++ b/packages/helpers/air-gap/tests/roundtrip.test.ts @@ -0,0 +1,131 @@ +import { DEFAULT_BLOCK_BYTES, MAX_MESSAGE_BYTES } from '../src/constants' +import { AirGapDecoder } from '../src/decoder' +import { AirGapEncoder } from '../src/encoder' +import { drain, lcg, message, shuffled } from './helpers' + +/** Feeds parts until the message decodes or `limit` parts have been sent. */ +function transmit( + msg: Uint8Array, + blockBytes: number, + keep: (seq: number) => boolean, + limit: number +): Uint8Array | null { + const enc = new AirGapEncoder(msg, blockBytes) + const dec = new AirGapDecoder() + for (let seq = 0; seq < limit; seq++) { + if (!keep(seq)) continue + if (dec.accept(enc.partAt(seq)).done) return dec.message() + } + return null +} + +describe('round trip', () => { + it('carries every message length across a block boundary', () => { + const blockBytes = 16 + for (const len of [1, 15, 16, 17, 31, 32, 33, 47, 48, 49, 64]) { + const msg = message(len) + const out = transmit(msg, blockBytes, () => true, 200) + expect(out).not.toBeNull() + expect(Array.from(out!)).toEqual(Array.from(msg)) + } + }) + + it('carries a message at every small block size', () => { + const msg = message(101) + for (const blockBytes of [1, 2, 3, 7, 8, 100, 101, 102, 1200]) { + const out = transmit(msg, blockBytes, () => true, 4000) + expect(out).not.toBeNull() + expect(Array.from(out!)).toEqual(Array.from(msg)) + } + }) + + it('carries the largest allowed message at the default block size', () => { + const msg = message(MAX_MESSAGE_BYTES) + const out = transmit(msg, DEFAULT_BLOCK_BYTES, () => true, 500) + expect(out).not.toBeNull() + expect(Array.from(out!)).toEqual(Array.from(msg)) + }) + + it('survives heavy, deterministically random frame loss', () => { + const random = lcg(0x5eed) + for (const [len, blockBytes] of [ + [700, 100], + [2400, 1200], + [6000, 1200], + [20000, 900] + ]) { + const msg = message(len) + // Drop three frames in five, the far side of what a shaky camera does. + const out = transmit(msg, blockBytes, () => random() > 0.6, 20000) + expect(out).not.toBeNull() + expect(Array.from(out!)).toEqual(Array.from(msg)) + } + }) + + it('survives arbitrary frame ordering', () => { + const random = lcg(0xc0ffee) + const msg = message(9000) // K = 8 at 1200 + const enc = new AirGapEncoder(msg, 1200) + for (let trial = 0; trial < 20; trial++) { + const dec = new AirGapDecoder() + // Two cycles' worth of parts, shuffled: a receiver that starts mid-cycle. + const out = drain(dec, enc, shuffled(2 * enc.blockCount, random)) + expect(out).not.toBeNull() + expect(Array.from(out!)).toEqual(Array.from(msg)) + } + }) + + it('needs only a small overhead over K parts on average', () => { + // The whole point of the fountain: a receiver that catches parts at random + // finishes in barely more than K reads. + const random = lcg(0xa11ce) + const msg = message(24000) // K = 20 at 1200 + const enc = new AirGapEncoder(msg, 1200) + let sent = 0 + const trials = 40 + for (let trial = 0; trial < trials; trial++) { + const dec = new AirGapDecoder() + // Start each receiver at a different point in an endless sender loop. + const offset = Math.floor(random() * 500) + let count = 0 + for (let i = 0; i < 4000; i++) { + count++ + if (dec.accept(enc.partAt(offset + i)).done) break + } + expect(dec.message()).not.toBeNull() + sent += count + } + expect(sent / trials).toBeLessThan(enc.blockCount * 2) + }) + + it('recovers after a corrupt part while the sender keeps looping', () => { + const msg = message(3700) + const enc = new AirGapEncoder(msg, 1200) + const dec = new AirGapDecoder() + for (let seq = 0; seq < 60; seq++) { + // Every third read comes back mangled, as a marginal scan does. + const text = seq % 3 === 0 ? `${enc.partAt(seq).slice(0, -4)}!!!!` : enc.partAt(seq) + if (dec.accept(text).done) break + } + expect(Array.from(dec.message()!)).toEqual(Array.from(msg)) + }) + + it('never blends two senders in view of the same camera', () => { + const msgA = message(2400) // K = 2 + const msgB = message(3700) // K = 4 + const encA = new AirGapEncoder(msgA, 1200) + const encB = new AirGapEncoder(msgB, 1200) + const dec = new AirGapDecoder() + // Alternating parts from two different messages starve each other — each + // one resets the session the other was building — but nothing is ever + // emitted from the mixture. + for (let seq = 0; seq < 40; seq++) { + expect(dec.accept(encA.partAt(seq)).done).toBe(false) + expect(dec.accept(encB.partAt(seq)).done).toBe(false) + expect(dec.message()).toBeNull() + } + // Point the camera at one of them and it decodes immediately. + expect(drain(dec, encA, [0, 1])).not.toBeNull() + expect(Array.from(dec.message()!)).toEqual(Array.from(msgA)) + }) +}) diff --git a/packages/helpers/air-gap/tests/vectors.test.ts b/packages/helpers/air-gap/tests/vectors.test.ts new file mode 100644 index 000000000..31b99af9e --- /dev/null +++ b/packages/helpers/air-gap/tests/vectors.test.ts @@ -0,0 +1,169 @@ +/** + * Frozen conformance vectors. + * + * These strings ARE the wire format. A change to the header layout, the base64 + * variant, the RNG, the degree distribution or the shuffle will break them, and + * that is the point: any implementation in any language must produce these exact + * strings for the same `(message, blockBytes, seq)`. Never regenerate a vector + * to make a test pass — a mismatch means the change is a protocol change. + */ +import { blocksForPart } from '../src/coding' +import { AirGapDecoder } from '../src/decoder' +import { AirGapEncoder } from '../src/encoder' +import { crc32 } from '../src/crc32' +import { drain, message, readHeader } from './helpers' + +describe('vector V0 — CRC-32 check value', () => { + it('crc32("123456789") is the IEEE check value', () => { + expect(crc32(new TextEncoder().encode('123456789'))).toBe(0xcbf43926) + }) +}) + +describe('the frozen part-to-blocks mapping', () => { + it('pins the block sets the wire format depends on', () => { + // These are the sets a decoder rebuilds from `seq` alone. They are the + // coding half of the vectors above; changing them changes the protocol. + expect(blocksForPart(3, 3)).toEqual([1, 2]) + expect(blocksForPart(4, 3)).toEqual([0]) + expect(blocksForPart(5, 5)).toEqual([2, 1, 3]) + }) + + it('always draws degree 1 when there is one source block', () => { + for (const seq of [1, 2, 7, 4096, 2 ** 31]) expect(blocksForPart(seq, 1)).toEqual([0]) + }) + + it('falls back to a fixed seed rather than a zero RNG state', () => { + // seq 0 is the only input whose xorshift seed would be 0, and a zeroed + // xorshift32 never leaves 0 — every draw would collapse to the same value. + // Unreachable from the encoder (seq 0 is systematic), pinned here so the + // fallback cannot be dropped from a port. + expect(blocksForPart(0, 4)).toEqual([2, 0]) + }) + + it('always returns distinct in-range indices, at most one per block', () => { + for (const k of [1, 2, 3, 5, 8, 55]) { + for (let seq = k; seq < k + 60; seq++) { + const indices = blocksForPart(seq, k) + expect(indices.length).toBeGreaterThanOrEqual(1) + expect(indices.length).toBeLessThanOrEqual(k) + expect(new Set(indices).size).toBe(indices.length) + for (const index of indices) { + expect(index).toBeGreaterThanOrEqual(0) + expect(index).toBeLessThan(k) + } + } + } + }) +}) + +describe('vector V1 — K = 1, "Hello" at blockBytes 8', () => { + const hello = new TextEncoder().encode('Hello') // 48 65 6c 6c 6f + const PART_0 = 'air-gap:AAAAAAABAAAABffRiYJIZWxsbwAAAA' + const PART_1 = 'air-gap:AAAAAQABAAAABffRiYJIZWxsbwAAAA' + const CRC = 0xf7d18982 + + it('has the pinned crc and block count', () => { + const enc = new AirGapEncoder(hello, 8) + expect(crc32(hello)).toBe(CRC) + expect(enc.blockCount).toBe(1) + }) + + it('renders the frozen part strings', () => { + const enc = new AirGapEncoder(hello, 8) + expect(enc.partAt(0)).toBe(PART_0) + // With one source block every part carries that block; only seq differs. + expect(enc.partAt(1)).toBe(PART_1) + }) + + it('carries the documented header fields', () => { + expect(readHeader(PART_0)).toEqual({ + seq: 0, + k: 1, + msgLen: 5, + crc: CRC, + payloadLength: 8 + }) + }) + + it('decodes to exactly the five message bytes from that part alone', () => { + const dec = new AirGapDecoder() + expect(dec.accept(PART_0).done).toBe(true) + expect(Array.from(dec.message()!)).toEqual([0x48, 0x65, 0x6c, 0x6c, 0x6f]) + }) + + it('decodes from a later part just as well', () => { + const dec = new AirGapDecoder() + expect(dec.accept(PART_1).done).toBe(true) + expect(new TextDecoder().decode(dec.message()!)).toBe('Hello') + }) +}) + +describe('vector V2 — K = 3, message(10) at blockBytes 4', () => { + // m[i] = (i * 31 + 7) & 0xff → 7,38,69,100,131,162,193,224,255,30 + const BYTES = [7, 38, 69, 100, 131, 162, 193, 224, 255, 30] + const CRC = 0x72c21f0b + const SYSTEMATIC = [ + 'air-gap:AAAAAAADAAAACnLCHwsHJkVk', + 'air-gap:AAAAAQADAAAACnLCHwuDosHg', + 'air-gap:AAAAAgADAAAACnLCHwv_HgAA' + ] + /** A degree-2 mix. */ + const PART_3 = 'air-gap:AAAAAwADAAAACnLCHwt8vMHg' + /** A degree-1 draw: the same payload as source block 0, under a later seq. */ + const PART_4 = 'air-gap:AAAABAADAAAACnLCHwsHJkVk' + + it('has the pinned message bytes, crc and block count', () => { + const msg = message(10) + expect(Array.from(msg)).toEqual(BYTES) + expect(crc32(msg)).toBe(CRC) + expect(new AirGapEncoder(msg, 4).blockCount).toBe(3) + }) + + it('renders the frozen systematic and fountain part strings', () => { + const enc = new AirGapEncoder(message(10), 4) + expect([enc.partAt(0), enc.partAt(1), enc.partAt(2)]).toEqual(SYSTEMATIC) + expect(enc.partAt(3)).toBe(PART_3) + expect(enc.partAt(4)).toBe(PART_4) + }) + + it('zero-pads the last block and still reports msgLen 10', () => { + expect(readHeader(SYSTEMATIC[2])).toEqual({ + seq: 2, + k: 3, + msgLen: 10, + crc: CRC, + payloadLength: 4 + }) + }) + + it('decodes from the three systematic parts alone', () => { + const dec = new AirGapDecoder() + expect(dec.accept(SYSTEMATIC[0]).done).toBe(false) + expect(dec.accept(SYSTEMATIC[1]).done).toBe(false) + expect(dec.accept(SYSTEMATIC[2]).done).toBe(true) + expect(Array.from(dec.message()!)).toEqual(BYTES) + }) + + it('substitutes a fountain part for a missed systematic one', () => { + const dec = new AirGapDecoder() + dec.accept(SYSTEMATIC[0]) + dec.accept(SYSTEMATIC[2]) + // Block 1 was never sent directly; PART_3 mixes it and peels out. + expect(dec.accept(PART_3).done).toBe(true) + expect(Array.from(dec.message()!)).toEqual(BYTES) + }) + + it('treats a degree-1 fountain part as pure redundancy once its block is known', () => { + const dec = new AirGapDecoder() + dec.accept(SYSTEMATIC[0]) + const s = dec.accept(PART_4) + expect(s).toEqual({ ok: true, done: false, have: 1, total: 3 }) + }) + + it('recovers from the frozen strings in any order', () => { + const enc = new AirGapEncoder(message(10), 4) + const dec = new AirGapDecoder() + expect(drain(dec, enc, [4, 3, 2, 1, 0])).not.toBeNull() + expect(Array.from(dec.message()!)).toEqual(BYTES) + }) +}) diff --git a/packages/helpers/air-gap/tsconfig.build.json b/packages/helpers/air-gap/tsconfig.build.json new file mode 100644 index 000000000..9e45bcab9 --- /dev/null +++ b/packages/helpers/air-gap/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "declaration": false, + "declarationMap": false, + "incremental": false + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/helpers/air-gap/tsconfig.json b/packages/helpers/air-gap/tsconfig.json new file mode 100644 index 000000000..4ddb0e65f --- /dev/null +++ b/packages/helpers/air-gap/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../../config/typescript/dual-runtime.json", + "compilerOptions": { + "target": "ES2021", + "lib": ["ES2021"], + "module": "ESNext", + "moduleResolution": "bundler", + "sourceMap": true, + "outDir": "out", + "allowJs": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "noImplicitAny": true, + "declaration": true, + "declarationMap": true, + "rootDir": ".", + "composite": true, + "noImplicitOverride": true, + "types": ["jest", "node"] + }, + "include": ["src/**/*", "tests/**/*"], + "exclude": ["node_modules", "dist", "out"], + "references": [] +} diff --git a/packages/helpers/air-gap/tsconfig.typecheck.json b/packages/helpers/air-gap/tsconfig.typecheck.json new file mode 100644 index 000000000..27164a2a2 --- /dev/null +++ b/packages/helpers/air-gap/tsconfig.typecheck.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "declaration": false, + "declarationMap": false, + "incremental": false, + "noEmit": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 610b5a6a8..ab25ea5a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,6 +174,36 @@ importers: specifier: ^8.1.5 version: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + packages/helpers/air-gap: + devDependencies: + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 + '@types/node': + specifier: ^26.1.2 + version: 26.1.2 + '@typescript/native': + specifier: npm:typescript@7.0.2 + version: typescript@7.0.2 + fast-check: + specifier: ^4.9.0 + version: 4.9.0 + jest: + specifier: ^30.4.2 + version: 30.4.2(@types/node@26.1.2)(ts-node@10.9.2(@types/node@26.1.2)(@typescript/typescript6@6.0.2)) + oxlint: + specifier: ^1.76.0 + version: 1.76.0 + ts-jest: + specifier: ^29.4.12 + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(@typescript/typescript6@6.0.2)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.4.2(@types/node@26.1.2)(ts-node@10.9.2(@types/node@26.1.2)(@typescript/typescript6@6.0.2))) + tsdown: + specifier: 0.22.14 + version: 0.22.14(@arethetypeswrong/core@0.18.5)(@typescript/typescript6@6.0.2)(publint@0.3.22)(tsx@4.23.1) + typescript: + specifier: npm:@typescript/typescript6@6.0.2 + version: '@typescript/typescript6@6.0.2' + packages/helpers/amountinator: dependencies: '@bsv/wallet-toolbox-client': diff --git a/scripts/check-package-license-tarballs.mjs b/scripts/check-package-license-tarballs.mjs index d3b4e49e9..f6389d2e4 100644 --- a/scripts/check-package-license-tarballs.mjs +++ b/scripts/check-package-license-tarballs.mjs @@ -74,8 +74,8 @@ async function mapWithConcurrency(items, concurrency, operation) { } const errors = (await mapWithConcurrency(packages, 8, verifyPackage)).flat() -if (packages.length !== 30) { - errors.push(`Expected 30 public npm packages, found ${packages.length}`) +if (packages.length !== 31) { + errors.push(`Expected 31 public npm packages, found ${packages.length}`) } if (errors.length > 0) { diff --git a/scripts/package-documentation.mjs b/scripts/package-documentation.mjs index c53d0d271..85e40a042 100644 --- a/scripts/package-documentation.mjs +++ b/scripts/package-documentation.mjs @@ -219,7 +219,7 @@ tags: [reference, packages, api, declarations, migrations, release-notes] # Package API, Declarations, and Migration Ledger -This page is generated from all 30 public manifests, package documentation, and +This page is generated from all 31 public manifests, package documentation, and \`governance/package-release-notes.json\`. It records source candidates without publishing them. CI rejects a version change unless its release classification, summary, and migration guidance are updated at the same time. diff --git a/scripts/package-documentation.test.mjs b/scripts/package-documentation.test.mjs index 9917959b8..51fe38d7e 100644 --- a/scripts/package-documentation.test.mjs +++ b/scripts/package-documentation.test.mjs @@ -5,8 +5,8 @@ import { loadPackageDocumentation, renderPackageDocumentation } from './package- test('package API and migration ledger covers every public package', async () => { const model = await loadPackageDocumentation() assert.deepEqual(model.errors, []) - assert.equal(model.packages.length, 30) - assert.equal(model.packages.filter(pkg => pkg.releaseType !== 'none').length, 30) + assert.equal(model.packages.length, 31) + assert.equal(model.packages.filter(pkg => pkg.releaseType !== 'none').length, 31) assert.ok(model.packages.every(pkg => pkg.docsPath?.startsWith('docs/packages/'))) const rendered = renderPackageDocumentation(model) diff --git a/scripts/package-license-policy.test.mjs b/scripts/package-license-policy.test.mjs index e15aa7416..2fe19ca4f 100644 --- a/scripts/package-license-policy.test.mjs +++ b/scripts/package-license-policy.test.mjs @@ -25,7 +25,7 @@ test('all package projects use the exact current Open BSV license', () => { assert.equal(LICENSE_FILE, 'LICENSE.txt') assert.equal(LICENSE_DECLARATION, 'SEE LICENSE IN LICENSE.txt') assert.equal(OCI_LICENSE_REFERENCE, 'LicenseRef-Open-BSV-License-6') - assert.equal(discoverPackageManifests().length, 46) + assert.equal(discoverPackageManifests().length, 47) assert.deepEqual(validatePackageLicenses(), []) }) diff --git a/scripts/package-release-artifacts.mjs b/scripts/package-release-artifacts.mjs index b1db703fe..05ad1c675 100644 --- a/scripts/package-release-artifacts.mjs +++ b/scripts/package-release-artifacts.mjs @@ -167,8 +167,8 @@ async function loadGovernedProjects() { path.join(REPOSITORY_ROOT, 'governance/repository-health/projects.json') ) const projects = governedProjects(registry) - if (projects.length !== 30) { - throw new Error(`expected 30 governed npm packages, found ${projects.length}`) + if (projects.length !== 31) { + throw new Error(`expected 31 governed npm packages, found ${projects.length}`) } return await Promise.all( projects.map(async project => { diff --git a/scripts/repository-health.test.mjs b/scripts/repository-health.test.mjs index 650a5f216..110700171 100644 --- a/scripts/repository-health.test.mjs +++ b/scripts/repository-health.test.mjs @@ -37,11 +37,11 @@ test('lint exclusion parsing rejects authored tests and benchmarks without backt ) }) -test('workspace discovery exactly matches the 37-project registry', () => { +test('workspace discovery exactly matches the 38-project registry', () => { const discovered = discoverWorkspaceProjects() - assert.equal(discovered.length, 37) - assert.equal(discovered.filter(project => project.manifest.private !== true).length, 30) + assert.equal(discovered.length, 38) + assert.equal(discovered.filter(project => project.manifest.private !== true).length, 31) assert.deepEqual( discovered.map(project => project.path), [...projects.projects].map(project => project.path).sort() @@ -65,8 +65,8 @@ test('current repository health controls and ratchet are internally consistent', const result = evaluateRepositoryHealth({ today: '2026-07-30' }) assert.deepEqual(result.errors, []) - assert.equal(result.projects.length, 37) - assert.equal(result.publicPackages, 30) + assert.equal(result.projects.length, 38) + assert.equal(result.publicPackages, 31) assert.equal(result.findings.length, 0) }) @@ -125,7 +125,7 @@ test('every public package declares supported runtime and canonical support meta project => project.manifest.private !== true ) - assert.equal(publicPackages.length, 30) + assert.equal(publicPackages.length, 31) for (const project of publicPackages) { assert.equal( project.manifest.engines?.node, @@ -178,7 +178,7 @@ test('every public package declares supported runtime and canonical support meta test('every public package has canonical, machine-verified consumer profiles', () => { const publicProjects = projects.projects.filter(project => project.release === 'npm-oidc') - assert.equal(publicProjects.length, 30) + assert.equal(publicProjects.length, 31) assert.ok(publicProjects.every(project => project.consumerProfiles.length > 0)) assert.deepEqual( [...new Set(publicProjects.flatMap(project => project.consumerProfiles))].sort(), diff --git a/scripts/test-governance.test.mjs b/scripts/test-governance.test.mjs index a0bb85f9c..4c958538e 100644 --- a/scripts/test-governance.test.mjs +++ b/scripts/test-governance.test.mjs @@ -32,11 +32,11 @@ test('current required, manual, live, resource, and conformance tests are govern assert.deepEqual(result.errors, []) assert.equal(result.summary.requiredDirectSkips, 2) - assert.equal(result.summary.propertySuites, 25) - assert.equal(result.summary.propertyPackages, 25) + assert.equal(result.summary.propertySuites, 26) + assert.equal(result.summary.propertyPackages, 26) assert.equal(result.summary.propertyExcludedPackages, 8) - assert.equal(result.summary.propertyClassifiedPackages, 33) - assert.equal(result.summary.mutationTargets, 25) + assert.equal(result.summary.propertyClassifiedPackages, 34) + assert.equal(result.summary.mutationTargets, 26) assert.equal(result.summary.manualAndLiveFiles, 32) assert.equal(result.summary.walletManualSuites, 30) assert.equal(result.summary.conformanceSkipFiles, 19) diff --git a/scripts/typescript-toolchain.test.mjs b/scripts/typescript-toolchain.test.mjs index 64b21e8bc..8b8885dd0 100644 --- a/scripts/typescript-toolchain.test.mjs +++ b/scripts/typescript-toolchain.test.mjs @@ -26,7 +26,7 @@ const governedManifest = { test('all tracked TypeScript projects use the governed side-by-side toolchain', () => { const report = inspectTypeScriptToolchain() - assert.equal(report.governed, 43) + assert.equal(report.governed, 44) assert.equal(report.codegen, 1) assert.ok(report.configurations > 100) assert.equal(report.profiles, 9) From a37a894633068093170ef42c3af28eaa48ef8d05 Mon Sep 17 00:00:00 2001 From: Deggen Date: Thu, 30 Jul 2026 13:29:49 -0500 Subject: [PATCH 2/7] fix(ci): add air-gap agent pointer and clear new Sonar findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 39 (#403) landed after this branch was cut and now requires a generated root-policy pointer at every governed project, so add packages/helpers/air-gap/AGENTS.md and bump the scoped-pointer count to 44. Also clears the ten new SonarCloud findings the zero-findings gate reported: - S8786 in src/base64url.ts and tests/helpers.ts — `=+$` backtracks super-linearly on a long run of '='. btoa pads to a multiple of four, so a bounded `={0,2}$` strips the same padding in linear time. - S7749 in src/constants.ts — uneven numeric separator groups in `0x1_0000_0000`; `2 ** 32` states the u32 bound directly. - S5906 across four test files — `toHaveLength` reports better than comparing a raw `.length`. No behavior change: the frozen conformance vectors and all 100 tests pass unchanged, src coverage stays at 100%, and the mutation score is 89.77%. Co-Authored-By: Claude Opus 5 (1M context) --- packages/helpers/air-gap/AGENTS.md | 10 ++++++++++ packages/helpers/air-gap/src/base64url.ts | 8 +++++++- packages/helpers/air-gap/src/constants.ts | 2 +- .../helpers/air-gap/tests/airGapCodec.property.test.ts | 2 +- packages/helpers/air-gap/tests/base64url.test.ts | 8 ++++---- packages/helpers/air-gap/tests/encoder.test.ts | 4 ++-- packages/helpers/air-gap/tests/helpers.ts | 6 +++++- scripts/contributor-policy.test.mjs | 2 +- 8 files changed, 31 insertions(+), 11 deletions(-) create mode 100644 packages/helpers/air-gap/AGENTS.md diff --git a/packages/helpers/air-gap/AGENTS.md b/packages/helpers/air-gap/AGENTS.md new file mode 100644 index 000000000..dcea67c80 --- /dev/null +++ b/packages/helpers/air-gap/AGENTS.md @@ -0,0 +1,10 @@ +# ts-stack agent instructions + +This project follows the repository-wide [agent instructions](../../../AGENTS.md) +and [contribution policy](../../../CONTRIBUTING.md). Read and follow both files +before changing anything in this directory. + +Do not add package-local agent or contribution conventions. Put +package-specific technical information in the package README, `docs/`, +`specs/`, or the applicable operator guide, and propose shared policy at the +repository root. diff --git a/packages/helpers/air-gap/src/base64url.ts b/packages/helpers/air-gap/src/base64url.ts index 825ff56d5..06ede5f60 100644 --- a/packages/helpers/air-gap/src/base64url.ts +++ b/packages/helpers/air-gap/src/base64url.ts @@ -24,7 +24,13 @@ export function toB64url(bytes: Uint8Array): string { // Every byte is below 0x100, so a code point is a single code unit here. binary += String.fromCodePoint(...bytes.subarray(i, i + CHUNK)) } - return globalThis.btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '') + // btoa pads to a multiple of four, so there are never more than two '=' — + // and a bounded quantifier keeps the strip linear rather than backtracking. + const base64 = globalThis.btoa(binary) + return base64 + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/={0,2}$/, '') } /** diff --git a/packages/helpers/air-gap/src/constants.ts b/packages/helpers/air-gap/src/constants.ts index 2760c89b6..fdddd580c 100644 --- a/packages/helpers/air-gap/src/constants.ts +++ b/packages/helpers/air-gap/src/constants.ts @@ -43,4 +43,4 @@ export const HEADER_BYTES = 14 export const MAX_BLOCK_COUNT = 0xffff /** Exclusive upper bound on `seq`, which the header carries as a u32. */ -export const MAX_SEQ_EXCLUSIVE = 0x1_0000_0000 +export const MAX_SEQ_EXCLUSIVE = 2 ** 32 diff --git a/packages/helpers/air-gap/tests/airGapCodec.property.test.ts b/packages/helpers/air-gap/tests/airGapCodec.property.test.ts index 13d3411d7..860af2ac0 100644 --- a/packages/helpers/air-gap/tests/airGapCodec.property.test.ts +++ b/packages/helpers/air-gap/tests/airGapCodec.property.test.ts @@ -139,7 +139,7 @@ describe('air-gap wire properties', () => { const enc = new AirGapEncoder(bytes, block) const part = enc.partAt(seq) expect(part.startsWith(AIR_GAP_PREFIX)).toBe(true) - expect(part.length).toBe(estimatePartCharLength(block)) + expect(part).toHaveLength(estimatePartCharLength(block)) }) ) }) diff --git a/packages/helpers/air-gap/tests/base64url.test.ts b/packages/helpers/air-gap/tests/base64url.test.ts index fc74f3557..1cc7dff0f 100644 --- a/packages/helpers/air-gap/tests/base64url.test.ts +++ b/packages/helpers/air-gap/tests/base64url.test.ts @@ -25,7 +25,7 @@ describe('base64url', () => { it('encodes the empty input as the empty string', () => { expect(toB64url(new Uint8Array(0))).toBe('') - expect(fromB64url('').length).toBe(0) + expect(fromB64url('')).toHaveLength(0) }) it('rejects characters outside the base64url alphabet', () => { @@ -43,8 +43,8 @@ describe('base64url', () => { }) it('accepts every unpadded body length that can encode bytes', () => { - expect(fromB64url('AA').length).toBe(1) - expect(fromB64url('AAA').length).toBe(2) - expect(fromB64url('AAAA').length).toBe(3) + expect(fromB64url('AA')).toHaveLength(1) + expect(fromB64url('AAA')).toHaveLength(2) + expect(fromB64url('AAAA')).toHaveLength(3) }) }) diff --git a/packages/helpers/air-gap/tests/encoder.test.ts b/packages/helpers/air-gap/tests/encoder.test.ts index 4b76a220e..4c4e8723e 100644 --- a/packages/helpers/air-gap/tests/encoder.test.ts +++ b/packages/helpers/air-gap/tests/encoder.test.ts @@ -17,7 +17,7 @@ describe('AirGapEncoder', () => { const fountain = enc.partAt(enc.blockCount + 1) expect(first.startsWith(AIR_GAP_PREFIX)).toBe(true) expect(fountain.startsWith(AIR_GAP_PREFIX)).toBe(true) - expect(first.length).toBe(fountain.length) + expect(first).toHaveLength(fountain.length) }) it('refuses an empty message', () => { @@ -145,7 +145,7 @@ describe('AirGapEncoder', () => { it('produces parts of exactly the estimated character length', () => { for (const blockBytes of [1, 2, 3, 8, 100, 1200, 1500]) { const enc = new AirGapEncoder(message(3000), blockBytes) - expect(enc.partAt(0).length).toBe(estimatePartCharLength(blockBytes)) + expect(enc.partAt(0)).toHaveLength(estimatePartCharLength(blockBytes)) } }) diff --git a/packages/helpers/air-gap/tests/helpers.ts b/packages/helpers/air-gap/tests/helpers.ts index aff041ccf..63db33343 100644 --- a/packages/helpers/air-gap/tests/helpers.ts +++ b/packages/helpers/air-gap/tests/helpers.ts @@ -33,9 +33,13 @@ export function partBytes(raw: string): Uint8Array { export function toPart(bytes: Uint8Array): string { let bin = '' for (const byte of bytes) bin += String.fromCodePoint(byte) + const base64 = globalThis.btoa(bin) return ( AIR_GAP_PREFIX + - globalThis.btoa(bin).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '') + base64 + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/={0,2}$/, '') ) } diff --git a/scripts/contributor-policy.test.mjs b/scripts/contributor-policy.test.mjs index 6bb76b72d..b76736fbf 100644 --- a/scripts/contributor-policy.test.mjs +++ b/scripts/contributor-policy.test.mjs @@ -14,7 +14,7 @@ import { test('current contributor and agent policy is uniform across the governed stack', () => { const result = evaluateContributorPolicy() assert.deepEqual(result.errors, []) - assert.equal(result.summary.scopedProjectsAndServices, 43) + assert.equal(result.summary.scopedProjectsAndServices, 44) assert.equal(result.summary.consolidatedLegacyAgentFiles, 31) assert.equal(result.summary.historicalGitHubFiles, 49) assert.equal(result.summary.retiredPackageContributionFiles, 8) From 8ada94853cf4657ce8d7fcd8b806b254850c3e22 Mon Sep 17 00:00:00 2001 From: Deggen Date: Thu, 30 Jul 2026 13:52:12 -0500 Subject: [PATCH 3/7] fix(ci): make coverage-other LCOV reach the patch-coverage gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding the first package whose coverage comes only from the "Coverage / other affected packages" lane exposed two gaps that made the 90% patch-coverage gate unsatisfiable for it. 1. That lane flattened report paths into `packages_helpers_air-gap_coverage_lcov.info`, but both the aggregate lane's Codecov glob and scripts/patch-coverage.mjs collect `*.lcov.info` — a dot, not an underscore. Every coverage-other package's LCOV was therefore invisible to the gate and never uploaded to Codecov. The artifact is now named `packages_helpers_air-gap.lcov.info`. 2. patch-coverage.mjs counted `jest.config.cjs` and similar as governed production source. Configuration is never instrumented, so a package that adds or edits a test config could never clear "changed production files absent from LCOV". Config files are now excluded, with a regression test. Verified locally against origin/main: the gate reports 100.00% (276/276 changed line/branch points). Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 5 ++++- scripts/patch-coverage.mjs | 6 +++++- scripts/patch-coverage.test.mjs | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f862c458..509a2342c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -817,7 +817,10 @@ jobs: mkdir -p .coverage-output while IFS= read -r report; do - artifact_name="${report//\//_}" + # The aggregate lane and the patch-coverage gate both collect + # *.lcov.info, so the suffix has to be a dot — a flattened + # "..._coverage_lcov.info" name is silently ignored by both. + artifact_name="$(printf '%s' "${report%/coverage/lcov.info}" | tr '/' '_').lcov.info" cp "$report" ".coverage-output/${artifact_name}" done < <(find packages -type f -path '*/coverage/lcov.info' | sort) echo "Coverage-capable packages are tested in this lane; packages without" diff --git a/scripts/patch-coverage.mjs b/scripts/patch-coverage.mjs index 17d3f20a5..48f0e46d0 100644 --- a/scripts/patch-coverage.mjs +++ b/scripts/patch-coverage.mjs @@ -11,7 +11,11 @@ export const REPOSITORY_ROOT = fileURLToPath(new URL('..', import.meta.url)) const EXCLUDED_SOURCE_PATTERNS = [ /(?:^|\/)__tests__(?:\/|$)/, /(?:^|\/)tests?(?:\/|$)/, - /\.(?:spec|test)\.[cm]?[jt]sx?$/ + /\.(?:spec|test)\.[cm]?[jt]sx?$/, + // Build and test configuration is never instrumented, so requiring it in + // LCOV is unsatisfiable: a package that adds or edits jest.config.cjs, + // vitest.config.ts or similar could never clear this gate. + /(?:^|\/)[^/]*\.config\.[cm]?[jt]s$/ ] function normalizedPath(value) { diff --git a/scripts/patch-coverage.test.mjs b/scripts/patch-coverage.test.mjs index 26ce00b3e..1a2717c8a 100644 --- a/scripts/patch-coverage.test.mjs +++ b/scripts/patch-coverage.test.mjs @@ -76,3 +76,19 @@ test('patch coverage fails closed when a changed production file is absent from missingFiles: ['packages/sdk/src/missing.ts'] }) }) + +test('patch coverage ignores build and test configuration, which is never instrumented', () => { + const changed = + changedLinesFromDiff(`diff --git a/packages/helpers/example/jest.config.cjs b/packages/helpers/example/jest.config.cjs ++++ b/packages/helpers/example/jest.config.cjs +@@ -0,0 +1,24 @@ +diff --git a/packages/helpers/example/vitest.config.ts b/packages/helpers/example/vitest.config.ts ++++ b/packages/helpers/example/vitest.config.ts +@@ -0,0 +1,12 @@ +diff --git a/packages/helpers/example/src/index.ts b/packages/helpers/example/src/index.ts ++++ b/packages/helpers/example/src/index.ts +@@ -0,0 +1 @@ +`) + + assert.deepEqual([...changed.keys()], ['packages/helpers/example/src/index.ts']) +}) From 0e4a8fb7c81e843d508ac8269ab5f71e289850e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 21:27:15 +0000 Subject: [PATCH 4/7] feat(air-gap)!: revise wire protocol to v1 (BRC-141) addressing review Wire format: versioned 23-byte header (ver u8 = 1, 8-byte session identity, seq u32, K u16, msgLen u32, crc32 u32). The fountain seed is the exact u32 product Math.imul(seq, 0x9e3779b1) - float multiplication diverged from seq 3,393,265 - and the degree draw is a single exact-integer inverse-CDF sample of the ideal soliton distribution, replacing the accidental two-draw sampler. Both are pinned by conformance vectors at the precision boundary, 0x7fffffff and 0xffffffff. No bsvpayf2 bit-compatibility is claimed; the legacy coding's defects are corrected, not reproduced. Decoder: rejects oversize strings before base64 work, enforces the new MAX_BLOCK_BYTES = 2048 ceiling (QR v40-L byte mode) on both sides, locks onto the first session and switches only after 3 consecutive parts of one foreign session, acknowledges completed sessions without mutation, and bounds duplicate tracking (65,536 seqs) and buffered mixes (1,024 parts / 4,096 indices) with liveness preserved. All bounds have adversarial tests; coverage stays 100% statements/branches/lines on src. Recovery language is now probabilistic everywhere, with a pinned K=3 stall-then-recovery regression (seqs 4,27,38,56,63,72 all reduce to block 0) and measured percentiles in the spec. QR capacity language corrected to byte mode with an assertion test. The literal NUL byte is gone from decoder.test.ts so the diff renders as text. Spec and conformance: normative spec at specs/transport/air-gap-optical.md (experimental), BRC-141 revised in place in bsv-blockchain/BRCs, and 31 implementation-neutral vectors at conformance/vectors/transport/ executed by both the package suite and the TS conformance runner via a new transport dispatcher (corpus 74->75 files, 6650->6681 vectors). Convergence with PW1/CHUNK/bsvpayf2 is tracked in #408. Governance: browser-library profile with browser-bundler/browser-esm consumers, tier-1 criticality, test:browser lane with browser-budget.json and a browser-artifact-policy entry; version rolled to 0.1.1 with updated release notes, baselines and experimental docs status. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013VPatJS7hMnrmHJG9iMRQQ --- conformance/META.json | 70 +-- conformance/PARITY_MATRIX.json | 24 +- .../runner/ts/dispatchers/transport.ts | 114 +++++ conformance/runner/ts/package.json | 1 + conformance/runner/ts/registry.ts | 7 +- .../vectors/transport/air-gap-optical.json | 454 ++++++++++++++++++ docs-site/src/lib/nav.ts | 3 +- docs/packages/helpers/air-gap.md | 66 ++- docs/reference/package-api-migrations.md | 70 +-- docs/reference/stack-facts.md | 12 +- docs/specs/air-gap-optical.md | 54 +++ docs/specs/index.md | 29 +- governance/browser-artifact-policy.json | 7 + governance/package-release-notes.json | 4 +- governance/repository-health/baselines.json | 8 +- governance/repository-health/projects.json | 8 +- governance/test-quality/policy.json | 3 +- packages/helpers/air-gap/README.md | 78 +-- packages/helpers/air-gap/browser-budget.json | 26 + packages/helpers/air-gap/package.json | 5 +- packages/helpers/air-gap/src/base64url.ts | 11 +- packages/helpers/air-gap/src/coding.ts | 48 +- packages/helpers/air-gap/src/constants.ts | 89 +++- packages/helpers/air-gap/src/decoder.ts | 213 +++++--- packages/helpers/air-gap/src/encoder.ts | 98 +++- packages/helpers/air-gap/src/helpers.ts | 20 +- packages/helpers/air-gap/src/index.ts | 23 +- .../tests/airGapCodec.property.test.ts | 71 ++- packages/helpers/air-gap/tests/api.test.ts | 43 +- .../helpers/air-gap/tests/decoder.test.ts | Bin 12914 -> 20661 bytes .../helpers/air-gap/tests/encoder.test.ts | 162 ++++--- packages/helpers/air-gap/tests/helpers.ts | 101 +++- .../helpers/air-gap/tests/roundtrip.test.ts | 58 ++- .../helpers/air-gap/tests/vectors.test.ts | 252 +++++----- pnpm-lock.yaml | 3 + specs/README.md | 46 +- specs/transport/air-gap-optical.md | 242 ++++++++++ 37 files changed, 1957 insertions(+), 566 deletions(-) create mode 100644 conformance/runner/ts/dispatchers/transport.ts create mode 100644 conformance/vectors/transport/air-gap-optical.json create mode 100644 docs/specs/air-gap-optical.md create mode 100644 packages/helpers/air-gap/browser-budget.json create mode 100644 specs/transport/air-gap-optical.md diff --git a/conformance/META.json b/conformance/META.json index 36c52621f..f583e7063 100644 --- a/conformance/META.json +++ b/conformance/META.json @@ -10,7 +10,8 @@ "auth", "payments", "storage", - "sync" + "sync", + "transport" ], "brc_coverage": { "BRC-42": [ @@ -19,22 +20,14 @@ "sdk.keys.publickey", "sdk.crypto.signature" ], - "BRC-74": [ - "sdk.transactions.merklepath", - "broadcast.merklepath" - ], - "BRC-77": [ - "sdk.compat.bsm" - ], + "BRC-74": ["sdk.transactions.merklepath", "broadcast.merklepath"], + "BRC-77": ["sdk.compat.bsm"], "BRC-31": [ "messaging.brc31.authrite-signature", "auth.brc31-handshake", "messaging.authsocket" ], - "BRC-29": [ - "wallet.brc29.payment-derivation", - "payments.brc29-payment-protocol" - ], + "BRC-29": ["wallet.brc29.payment-derivation", "payments.brc29-payment-protocol"], "BRC-100": [ "wallet.brc100.getpublickey", "wallet.brc100.createhmac", @@ -66,46 +59,23 @@ "wallet.brc100.getversion", "wallet.storage.adapterconformance" ], - "BRC-121": [ - "payments.brc121" - ], - "BRC-26": [ - "storage.uhrp-http" - ], - "BRC-62": [ - "overlay.submit" - ], - "BRC-22": [ - "overlay.lookup", - "overlay.topicmanagement" - ], - "BRC-20": [ - "broadcast.arcsubmit", - "broadcast.merklepath" - ], - "BRC-21": [ - "sync.gasprotocol" - ], - "BRC-40": [ - "sync.brc40" - ], - "BRC-14": [ - "sdk.scripts.evaluation" - ], - "merkle-service": [ - "broadcast.merkle-service" - ], - "message-box": [ - "messaging.messagebox-http" - ], - "chaintracks-v2": [ - "sync.chaintracks-v2-http" - ] + "BRC-121": ["payments.brc121"], + "BRC-26": ["storage.uhrp-http"], + "BRC-62": ["overlay.submit"], + "BRC-22": ["overlay.lookup", "overlay.topicmanagement"], + "BRC-20": ["broadcast.arcsubmit", "broadcast.merklepath"], + "BRC-21": ["sync.gasprotocol"], + "BRC-40": ["sync.brc40"], + "BRC-14": ["sdk.scripts.evaluation"], + "merkle-service": ["broadcast.merkle-service"], + "message-box": ["messaging.messagebox-http"], + "chaintracks-v2": ["sync.chaintracks-v2-http"], + "BRC-141": ["transport.air-gap-optical"] }, "stats": { - "total_files": 74, - "total_vectors": 6650, - "last_updated": "2026-07-27" + "total_files": 75, + "total_vectors": 6681, + "last_updated": "2026-07-30" }, "regression_index": { "beef-v2-txid-panic": "go-sdk#306", diff --git a/conformance/PARITY_MATRIX.json b/conformance/PARITY_MATRIX.json index 100998d8b..c06333e4a 100644 --- a/conformance/PARITY_MATRIX.json +++ b/conformance/PARITY_MATRIX.json @@ -1,21 +1,21 @@ { "schema_version": "1.0", - "generated_at": "2026-07-27", + "generated_at": "2026-07-30", "source": "ts-stack conformance corpus", "description": "Machine-readable parity status for cross-language SDK implementations (Go, Rust, Python). Use this to track and drive conformance.", "summary": { - "total_files": 74, - "total_vectors": 6650, - "fully_required_files": 55, + "total_files": 75, + "total_vectors": 6681, + "fully_required_files": 56, "files_with_intended": 17, "files_with_mixed_status": 15, "vectors_by_status": { - "required": 6446, + "required": 6477, "intended": 204, "skipped": 7 }, "by_reason_category": { - "fully_supported": 1234, + "fully_supported": 1265, "governed_vector_skip": 50, "historical_regression": 36, "partial_ts_behavioral_difference": 5116, @@ -568,6 +568,18 @@ "reason_category": "fully_supported", "categories": [] }, + { + "path": "transport/air-gap-optical.json", + "id": "transport.air-gap-optical", + "total_vectors": 31, + "file_level_parity": "required", + "effective_status": "required", + "required_count": 31, + "intended_count": 0, + "skipped_count": 0, + "reason_category": "fully_supported", + "categories": [] + }, { "path": "wallet/brc100/abortaction.json", "id": "wallet.brc100.abortaction", diff --git a/conformance/runner/ts/dispatchers/transport.ts b/conformance/runner/ts/dispatchers/transport.ts new file mode 100644 index 000000000..d46ed20cb --- /dev/null +++ b/conformance/runner/ts/dispatchers/transport.ts @@ -0,0 +1,114 @@ +/** + * Transport dispatcher — air-gap optical transport (BRC-141). + * + * Categories: + * air-gap-optical — wire protocol v1 of @bsv/air-gap: deterministic + * fountain-coded part encoding, camera-safe decoding, session locking, + * and hostile-input rejection. + * + * Vector operations (input.operation): + * crc32 — IEEE CRC-32 check value of a hex payload + * part-char-length — exact rendered part length for a block size + * encode-part — one deterministic part string for + * (message, blockBytes, sessionId, seq) + * decode — feed parts in order; the message must complete and + * match the expected hex exactly + * progress — feed parts in order; assert final progress counters + * (used for the linear-dependence stall regression) + * accept-one — a single scanned string must be rejected without a + * throw and without producing a message + * + * Binary data is lowercase hex in the vectors (corpus convention); part + * strings travel verbatim since they are the wire format under test. + */ + +import { expect } from '@jest/globals' +import { AirGapDecoder, AirGapEncoder, crc32, estimatePartCharLength } from '@bsv/air-gap' + +export const categories: ReadonlyArray = ['air-gap-optical'] + +function hexToBytes(hex: string): Uint8Array { + const out = new Uint8Array(hex.length / 2) + for (let i = 0; i < out.length; i++) { + out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return out +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map(b => b.toString(16).padStart(2, '0')) + .join('') +} + +function getString(m: Record, key: string): string { + const v = m[key] + return typeof v === 'string' ? v : '' +} + +function getNumber(m: Record, key: string): number { + const v = m[key] + return typeof v === 'number' ? v : Number.NaN +} + +function getStrings(m: Record, key: string): string[] { + const v = m[key] + return Array.isArray(v) ? v.filter((item): item is string => typeof item === 'string') : [] +} + +export function dispatch( + category: string, + input: Record, + expected: Record +): void { + if (category !== 'air-gap-optical') { + throw new Error(`not implemented: transport category '${category}'`) + } + const operation = getString(input, 'operation') + switch (operation) { + case 'crc32': { + const value = crc32(hexToBytes(getString(input, 'message_hex'))) + expect(value.toString(16).padStart(8, '0')).toBe(expected.crc32_hex) + return + } + case 'part-char-length': { + expect(estimatePartCharLength(getNumber(input, 'block_bytes'))).toBe(expected.chars) + return + } + case 'encode-part': { + const encoder = new AirGapEncoder(hexToBytes(getString(input, 'message_hex')), { + blockBytes: getNumber(input, 'block_bytes'), + sessionId: hexToBytes(getString(input, 'session_id_hex')) + }) + expect(encoder.partAt(getNumber(input, 'seq'))).toBe(expected.part) + return + } + case 'decode': { + const decoder = new AirGapDecoder() + let done = false + for (const part of getStrings(input, 'parts')) done = decoder.accept(part).done || done + expect(done).toBe(true) + const out = decoder.message() + expect(out).not.toBeNull() + expect(bytesToHex(out as Uint8Array)).toBe(expected.message_hex) + return + } + case 'progress': { + const decoder = new AirGapDecoder() + let last = decoder.accept('') + for (const part of getStrings(input, 'parts')) last = decoder.accept(part) + expect(last.have).toBe(expected.have) + expect(last.total).toBe(expected.total) + expect(last.done).toBe(expected.done) + return + } + case 'accept-one': { + const decoder = new AirGapDecoder() + expect(decoder.accept(getString(input, 'text')).ok).toBe(expected.ok) + expect(decoder.message()).toBeNull() + return + } + default: + throw new Error(`not implemented: transport operation '${operation}'`) + } +} diff --git a/conformance/runner/ts/package.json b/conformance/runner/ts/package.json index 6ec3f9e1c..4b8ccaf6c 100644 --- a/conformance/runner/ts/package.json +++ b/conformance/runner/ts/package.json @@ -9,6 +9,7 @@ "typecheck": "tsc --noEmit --pretty false" }, "devDependencies": { + "@bsv/air-gap": "workspace:^", "@bsv/sdk": "workspace:^", "@jest/globals": "^30.4.1", "@types/node": "^26.1.2", diff --git a/conformance/runner/ts/registry.ts b/conformance/runner/ts/registry.ts index 915f04c57..2f3d965d0 100644 --- a/conformance/runner/ts/registry.ts +++ b/conformance/runner/ts/registry.ts @@ -25,6 +25,7 @@ import * as overlay from './dispatchers/overlay.js' import * as payments from './dispatchers/payments.js' import * as storage from './dispatchers/storage.js' import * as sync from './dispatchers/sync.js' +import * as transport from './dispatchers/transport.js' import * as walletStorage from './dispatchers/wallet-storage.js' export type DispatchFn = ( @@ -56,7 +57,8 @@ const DISPATCHERS: Array<{ { domain: 'overlay', dispatcher: overlay }, { domain: 'payments', dispatcher: payments }, { domain: 'storage', dispatcher: storage }, - { domain: 'sync', dispatcher: sync } + { domain: 'sync', dispatcher: sync }, + { domain: 'transport', dispatcher: transport } ] const CATEGORY_MAP = new Map() @@ -84,7 +86,8 @@ const PREFIX_MAP: Array<[string, Route]> = [ ['overlay.', { domain: 'overlay', dispatch: overlay.dispatch }], ['payments.', { domain: 'payments', dispatch: payments.dispatch }], ['storage.', { domain: 'storage', dispatch: storage.dispatch }], - ['sync.', { domain: 'sync', dispatch: sync.dispatch }] + ['sync.', { domain: 'sync', dispatch: sync.dispatch }], + ['transport.', { domain: 'transport', dispatch: transport.dispatch }] ] /** diff --git a/conformance/vectors/transport/air-gap-optical.json b/conformance/vectors/transport/air-gap-optical.json new file mode 100644 index 000000000..74bd3d05a --- /dev/null +++ b/conformance/vectors/transport/air-gap-optical.json @@ -0,0 +1,454 @@ +{ + "$schema": "../../schema/vector.schema.json", + "id": "transport.air-gap-optical", + "name": "Air-Gap Optical Transport v1 (BRC-141)", + "brc": ["BRC-141"], + "version": "1.0.0", + "reference_impl": "@bsv/air-gap@0.1.1", + "parity_class": "required", + "vectors": [ + { + "id": "transport.air-gap-optical.crc32.1", + "description": "IEEE CRC-32 check value for ASCII \"123456789\"", + "input": { + "operation": "crc32", + "message_hex": "313233343536373839" + }, + "expected": { + "crc32_hex": "cbf43926" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.length.2", + "description": "exact rendered part length for blockBytes 1", + "input": { + "operation": "part-char-length", + "block_bytes": 1 + }, + "expected": { + "chars": 40 + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.length.3", + "description": "exact rendered part length for blockBytes 1200", + "input": { + "operation": "part-char-length", + "block_bytes": 1200 + }, + "expected": { + "chars": 1639 + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.length.4", + "description": "exact rendered part length for blockBytes 2048", + "input": { + "operation": "part-char-length", + "block_bytes": 2048 + }, + "expected": { + "chars": 2770 + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.encode.5", + "description": "K=1 \"Hello\" blockBytes 8 seq 0 (systematic/fountain of a single block)", + "input": { + "operation": "encode-part", + "message_hex": "48656c6c6f", + "block_bytes": 8, + "session_id_hex": "0102030405060708", + "seq": 0 + }, + "expected": { + "part": "air-gap:AQECAwQFBgcIAAAAAAABAAAABffRiYJIZWxsbwAAAA" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.encode.6", + "description": "K=1 \"Hello\" blockBytes 8 seq 1 (systematic/fountain of a single block)", + "input": { + "operation": "encode-part", + "message_hex": "48656c6c6f", + "block_bytes": 8, + "session_id_hex": "0102030405060708", + "seq": 1 + }, + "expected": { + "part": "air-gap:AQECAwQFBgcIAAAAAQABAAAABffRiYJIZWxsbwAAAA" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.encode.7", + "description": "K=3 pseudo-random 10-byte message blockBytes 4 seq 0", + "input": { + "operation": "encode-part", + "message_hex": "0726456483a2c1e0ff1e", + "block_bytes": 4, + "session_id_hex": "0102030405060708", + "seq": 0 + }, + "expected": { + "part": "air-gap:AQECAwQFBgcIAAAAAAADAAAACnLCHwsHJkVk" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.encode.8", + "description": "K=3 pseudo-random 10-byte message blockBytes 4 seq 1", + "input": { + "operation": "encode-part", + "message_hex": "0726456483a2c1e0ff1e", + "block_bytes": 4, + "session_id_hex": "0102030405060708", + "seq": 1 + }, + "expected": { + "part": "air-gap:AQECAwQFBgcIAAAAAQADAAAACnLCHwuDosHg" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.encode.9", + "description": "K=3 pseudo-random 10-byte message blockBytes 4 seq 2", + "input": { + "operation": "encode-part", + "message_hex": "0726456483a2c1e0ff1e", + "block_bytes": 4, + "session_id_hex": "0102030405060708", + "seq": 2 + }, + "expected": { + "part": "air-gap:AQECAwQFBgcIAAAAAgADAAAACnLCHwv_HgAA" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.encode.10", + "description": "K=3 pseudo-random 10-byte message blockBytes 4 seq 3", + "input": { + "operation": "encode-part", + "message_hex": "0726456483a2c1e0ff1e", + "block_bytes": 4, + "session_id_hex": "0102030405060708", + "seq": 3 + }, + "expected": { + "part": "air-gap:AQECAwQFBgcIAAAAAwADAAAACnLCHwt8vMHg" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.encode.11", + "description": "K=3 pseudo-random 10-byte message blockBytes 4 seq 4", + "input": { + "operation": "encode-part", + "message_hex": "0726456483a2c1e0ff1e", + "block_bytes": 4, + "session_id_hex": "0102030405060708", + "seq": 4 + }, + "expected": { + "part": "air-gap:AQECAwQFBgcIAAAABAADAAAACnLCHwsHJkVk" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.encode.12", + "description": "K=5 boundary seq 3393264 — u32 modular seed (Math.imul); a float-multiply port diverges here or above", + "input": { + "operation": "encode-part", + "message_hex": "0726456483a2c1e0ff1e3d5c7b9ab9d8f7163554", + "block_bytes": 4, + "session_id_hex": "0000000000000000", + "seq": 3393264 + }, + "expected": { + "part": "air-gap:AQAAAAAAAAAAADPG8AAFAAAAFNHjUJUICAgI" + }, + "tags": ["edge-case", "seed-precision"] + }, + { + "id": "transport.air-gap-optical.encode.13", + "description": "K=5 boundary seq 3393265 — u32 modular seed (Math.imul); a float-multiply port diverges here or above", + "input": { + "operation": "encode-part", + "message_hex": "0726456483a2c1e0ff1e3d5c7b9ab9d8f7163554", + "block_bytes": 4, + "session_id_hex": "0000000000000000", + "seq": 3393265 + }, + "expected": { + "part": "air-gap:AQAAAAAAAAAAADPG8QAFAAAAFNHjUJX4OHg4" + }, + "tags": ["edge-case", "seed-precision"] + }, + { + "id": "transport.air-gap-optical.encode.14", + "description": "K=5 boundary seq 2147483647 — u32 modular seed (Math.imul); a float-multiply port diverges here or above", + "input": { + "operation": "encode-part", + "message_hex": "0726456483a2c1e0ff1e3d5c7b9ab9d8f7163554", + "block_bytes": 4, + "session_id_hex": "0000000000000000", + "seq": 2147483647 + }, + "expected": { + "part": "air-gap:AQAAAAAAAAAAf____wAFAAAAFNHjUJV8vPy8" + }, + "tags": ["edge-case", "seed-precision"] + }, + { + "id": "transport.air-gap-optical.encode.15", + "description": "K=5 boundary seq 4294967295 — u32 modular seed (Math.imul); a float-multiply port diverges here or above", + "input": { + "operation": "encode-part", + "message_hex": "0726456483a2c1e0ff1e3d5c7b9ab9d8f7163554", + "block_bytes": 4, + "session_id_hex": "0000000000000000", + "seq": 4294967295 + }, + "expected": { + "part": "air-gap:AQAAAAAAAAAA_____wAFAAAAFNHjUJUAAAAA" + }, + "tags": ["edge-case", "seed-precision"] + }, + { + "id": "transport.air-gap-optical.decode.16", + "description": "K=3 systematic cycle decodes to the exact message", + "input": { + "operation": "decode", + "parts": [ + "air-gap:AQECAwQFBgcIAAAAAAADAAAACnLCHwsHJkVk", + "air-gap:AQECAwQFBgcIAAAAAQADAAAACnLCHwuDosHg", + "air-gap:AQECAwQFBgcIAAAAAgADAAAACnLCHwv_HgAA" + ] + }, + "expected": { + "message_hex": "0726456483a2c1e0ff1e" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.decode.17", + "description": "K=3 fountain part substitutes for a missed systematic part", + "input": { + "operation": "decode", + "parts": [ + "air-gap:AQECAwQFBgcIAAAAAAADAAAACnLCHwsHJkVk", + "air-gap:AQECAwQFBgcIAAAAAgADAAAACnLCHwv_HgAA", + "air-gap:AQECAwQFBgcIAAAAAwADAAAACnLCHwt8vMHg" + ] + }, + "expected": { + "message_hex": "0726456483a2c1e0ff1e" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.decode.18", + "description": "single part completes a single-block message", + "input": { + "operation": "decode", + "parts": ["air-gap:AQECAwQFBgcIAAAAAQABAAAABffRiYJIZWxsbwAAAA"] + }, + "expected": { + "message_hex": "48656c6c6f" + }, + "tags": ["happy-path"] + }, + { + "id": "transport.air-gap-optical.session.19", + "description": "one foreign frame is rejected; the locked session still completes", + "input": { + "operation": "decode", + "parts": [ + "air-gap:AQECAwQFBgcIAAAAAAADAAAACnLCHwsHJkVk", + "air-gap:ARESExQVFhcYAAAAAAACAAAACKdWBCgHJkVk", + "air-gap:AQECAwQFBgcIAAAAAQADAAAACnLCHwuDosHg", + "air-gap:AQECAwQFBgcIAAAAAgADAAAACnLCHwv_HgAA" + ] + }, + "expected": { + "message_hex": "0726456483a2c1e0ff1e" + }, + "tags": ["adversarial"] + }, + { + "id": "transport.air-gap-optical.session.20", + "description": "three consecutive foreign parts switch the decoder to the new session", + "input": { + "operation": "decode", + "parts": [ + "air-gap:AQECAwQFBgcIAAAAAAADAAAACnLCHwsHJkVk", + "air-gap:ARESExQVFhcYAAAAAAACAAAACKdWBCgHJkVk", + "air-gap:ARESExQVFhcYAAAAAQACAAAACKdWBCiDosHg", + "air-gap:ARESExQVFhcYAAAAAgACAAAACKdWBCiEhISE", + "air-gap:ARESExQVFhcYAAAAAAACAAAACKdWBCgHJkVk", + "air-gap:ARESExQVFhcYAAAAAQACAAAACKdWBCiDosHg" + ] + }, + "expected": { + "message_hex": "0726456483a2c1e0" + }, + "tags": ["adversarial"] + }, + { + "id": "transport.air-gap-optical.session.21", + "description": "K=3 linearly dependent fountain parts stall at 1/3 recovered — recovery is probabilistic, receivers keep scanning", + "input": { + "operation": "progress", + "parts": [ + "air-gap:AQAAAAAAAAAAAAAABAADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAAGwADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAAJgADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAAOAADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAAPwADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAASAADAAAAHgatDaIHJkVkg6LB4P8e" + ] + }, + "expected": { + "have": 1, + "total": 3, + "done": false + }, + "tags": ["edge-case", "stall"] + }, + { + "id": "transport.air-gap-optical.session.22", + "description": "the stalled session completes once the remaining systematic parts arrive", + "input": { + "operation": "decode", + "parts": [ + "air-gap:AQAAAAAAAAAAAAAABAADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAAGwADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAAJgADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAAOAADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAAPwADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAASAADAAAAHgatDaIHJkVkg6LB4P8e", + "air-gap:AQAAAAAAAAAAAAAAAQADAAAAHgatDaI9XHuaudj3FjVU", + "air-gap:AQAAAAAAAAAAAAAAAgADAAAAHgatDaJzkrHQ7w4tTGuK" + ] + }, + "expected": { + "message_hex": "0726456483a2c1e0ff1e3d5c7b9ab9d8f71635547392b1d0ef0e2d4c6b8a" + }, + "tags": ["edge-case", "stall"] + }, + { + "id": "transport.air-gap-optical.reject.23", + "description": "legacy bsvpayf2 frame is not accepted", + "input": { + "operation": "accept-one", + "text": "bsvpayf2:AAAAAAAAAAAAAAAA" + }, + "expected": { + "ok": false + }, + "tags": ["adversarial"] + }, + { + "id": "transport.air-gap-optical.reject.24", + "description": "BRC-225 TKQR1 frame is not accepted", + "input": { + "operation": "accept-one", + "text": "TKQR1|0011223344556677|0|1|aGk=" + }, + "expected": { + "ok": false + }, + "tags": ["adversarial"] + }, + { + "id": "transport.air-gap-optical.reject.25", + "description": "wire version 0 is rejected", + "input": { + "operation": "accept-one", + "text": "air-gap:AAECAwQFBgcIAAAAAAADAAAACnLCHwsHJkVk" + }, + "expected": { + "ok": false + }, + "tags": ["adversarial"] + }, + { + "id": "transport.air-gap-optical.reject.26", + "description": "wire version 2 is rejected", + "input": { + "operation": "accept-one", + "text": "air-gap:AgECAwQFBgcIAAAAAAADAAAACnLCHwsHJkVk" + }, + "expected": { + "ok": false + }, + "tags": ["adversarial"] + }, + { + "id": "transport.air-gap-optical.reject.27", + "description": "header-only body is rejected", + "input": { + "operation": "accept-one", + "text": "air-gap:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "expected": { + "ok": false + }, + "tags": ["adversarial"] + }, + { + "id": "transport.air-gap-optical.reject.28", + "description": "payload longer than MAX_BLOCK_BYTES is rejected", + "input": { + "operation": "accept-one", + "text": "air-gap:AQECAwQFBgcIAAAAAAADAAAACnLCHwsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "expected": { + "ok": false + }, + "tags": ["adversarial"] + }, + { + "id": "transport.air-gap-optical.reject.29", + "description": "scanned text longer than the maximum part length is rejected before decoding", + "input": { + "operation": "accept-one", + "text": "air-gap:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "expected": { + "ok": false + }, + "tags": ["adversarial"] + }, + { + "id": "transport.air-gap-optical.reject.30", + "description": "base64url with padding is rejected", + "input": { + "operation": "accept-one", + "text": "air-gap:AQECAwQFBgcIAAAAAAADAAAACnLCHwsHJkVk=" + }, + "expected": { + "ok": false + }, + "tags": ["adversarial"] + }, + { + "id": "transport.air-gap-optical.reject.31", + "description": "whitespace inside the body is rejected", + "input": { + "operation": "accept-one", + "text": "air-gap:AQECAwQFBgcI AAAAAAADAAAACnLCHwsHJkVk" + }, + "expected": { + "ok": false + }, + "tags": ["adversarial"] + } + ] +} diff --git a/docs-site/src/lib/nav.ts b/docs-site/src/lib/nav.ts index 432b73fb1..608daedfd 100644 --- a/docs-site/src/lib/nav.ts +++ b/docs-site/src/lib/nav.ts @@ -145,7 +145,8 @@ export const NAV: NavSection[] = [ { label: 'Merkle Service', href: '/specs/merkle-service/' }, { label: 'Storage Adapter', href: '/specs/storage-adapter/' }, { label: 'GASP Sync', href: '/specs/gasp-sync/' }, - { label: 'UHRP', href: '/specs/uhrp/' } + { label: 'UHRP', href: '/specs/uhrp/' }, + { label: 'Air-Gap Optical (BRC-141)', href: '/specs/air-gap-optical/' } ] }, { diff --git a/docs/packages/helpers/air-gap.md b/docs/packages/helpers/air-gap.md index f101f267a..fdaec91be 100644 --- a/docs/packages/helpers/air-gap.md +++ b/docs/packages/helpers/air-gap.md @@ -3,20 +3,20 @@ id: pkg-air-gap title: '@bsv/air-gap' kind: package domain: helpers -version: '0.1.0' +version: '0.1.1' source_repo: 'bsv-blockchain/ts-stack' last_updated: '2026-07-30' last_verified: '2026-07-30' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/air-gap' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/air-gap' -status: stable -tags: [helpers, air-gap, qr, optical] +status: experimental +tags: [helpers, air-gap, qr, optical, transport] --- # @bsv/air-gap -> One-directional optical air-gap transport for arbitrary bytes — fountain-coded QR parts that survive missed camera frames with no back-channel, zero runtime dependencies, and CRC32 payload integrity. +> One-directional optical air-gap transport for arbitrary bytes — the reference implementation of the experimental BRC-141 wire protocol: fountain-coded QR parts that survive missed camera frames with no back-channel, zero runtime dependencies, session locking against stray frames, and CRC32 payload integrity. ## Install @@ -33,8 +33,12 @@ import { AirGapDecoder, AirGapEncoder } from '@bsv/air-gap' // Sender: display parts. The application owns the sequence number and cadence. const encoder = new AirGapEncoder(payloadBytes) +const cycle = encoder.blockCount * 64 let seq = 0 -setInterval(() => renderQrCode(encoder.partAt(seq++)), 200) +setInterval(() => { + renderQrCode(encoder.partAt(seq)) + seq = (seq + 1) % cycle // loop — the repeating systematic prefix guarantees recovery +}, 200) // Receiver: feed every scan in. const decoder = new AirGapDecoder() @@ -49,31 +53,35 @@ onBarcodeScan(text => { ## What it provides -- **AirGapEncoder** — Splits a message into `K` source blocks and renders part `seq` as a wire string; pure function of `(message, blockBytes, seq)` +- **AirGapEncoder** — Splits a message into `K` source blocks and renders part `seq` as a wire string; pure function of `(message, blockBytes, sessionId, seq)` - **AirGapDecoder** — Accepts scanned strings, peels fountain parts, and emits the CRC-verified payload - **Systematic prefix** — The first `K` parts are the source blocks verbatim, so one clean camera cycle decodes with zero overhead -- **Loss tolerance** — Later parts are interchangeable XOR mixes; any `K + ε` distinct parts reconstruct the message -- **Camera safety** — `accept` never throws and never emits partial or unverified bytes -- **Session isolation** — `(K, msgLen, crc32)` identifies a message; a foreign part resets the decoder instead of blending -- **Block-size pin** — The first accepted part fixes the payload length for the session, rejecting mismatched frames -- **Zero runtime dependencies** — No `@bsv/sdk`, no polyfills beyond `btoa` / `atob` +- **Loss tolerance** — Later parts are interchangeable XOR mixes; with high probability `K + ε` distinct parts reconstruct the message, and a looping sender makes recovery certain +- **Camera safety** — `accept` never throws, never emits partial or unverified bytes, and rejects oversize strings before doing any work +- **Session locking** — An 8-byte session identity names each stream; one stray frame from another sender is rejected, and only three consecutive foreign parts switch the decoder +- **Hostile-input bounds** — Duplicate tracking and mix buffering are hard-capped, so a broken or malicious sender cannot exhaust decoder memory +- **Zero runtime dependencies** — No `@bsv/sdk`, no polyfills beyond `btoa` / `atob` / `crypto.getRandomValues` ## Runtime and package compatibility The package root provides matching typed entry points for Node.js ESM and -CommonJS consumers. The published tarball is checked with `publint`, strict +CommonJS consumers, and the published tarball is additionally verified as a +browser artifact: exact-tarball bundling with Vite and esbuild against a +governed size budget (`browser-budget.json`), plus `publint`, strict `@arethetypeswrong/core` resolution, and clean installs that import and -require every public export. Node.js 22 or newer is supported. +require every public export. Node.js 22 or newer, evergreen browsers. ## Common patterns ### Animate a multi-part message ```typescript -const encoder = new AirGapEncoder(payload, 1200) +const encoder = new AirGapEncoder(payload, { blockBytes: 1200 }) +const cycle = encoder.blockCount * 64 let seq = 0 -const timer = setInterval(() => renderQrCode(encoder.partAt(seq++)), 200) -// seq is unbounded: keep looping until the receiver signals success out-of-band +const timer = setInterval(() => renderQrCode(encoder.partAt(seq++ % cycle)), 200) +// Loop until the receiver signals success out-of-band; seq is a finite u32, +// and re-running the systematic prefix is what makes recovery deterministic. ``` ### Display a single-part message statically @@ -99,7 +107,9 @@ onBarcodeScan(text => { ```typescript import { estimatePartCharLength } from '@bsv/air-gap' -estimatePartCharLength(1200) // 1627 characters, exactly +estimatePartCharLength(1200) // 1639 characters, exactly +// Compare against BYTE-mode QR capacity (base64url rules out alphanumeric +// mode): version 40 holds 2953 bytes at EC L, 1663 at EC Q. ``` ### Abandon a scan @@ -110,11 +120,12 @@ onCancel(() => decoder.reset()) ## Key concepts -- **Fountain coding** — Luby-transform parts with an ideal-soliton degree, not numbered chunks; a missed frame costs almost nothing -- **Determinism** — Part contents are a pure function of `seq`; the decoder rebuilds each part's block set from the header alone -- **Wire part** — `air-gap:` + unpadded base64url of a 14-byte big-endian header (`seq` u32, `K` u16, `msgLen` u32, `crc32` u32) and exactly one block -- **Block size off the wire** — Inferred from payload length, so every part is the same size and each application picks its own symbol density -- **Session key** — `(K, msgLen, crc32)`; a change means a different message and a full decoder reset +- **Fountain coding** — Luby-transform parts with an exact-integer ideal-soliton degree draw, not numbered chunks; a missed frame costs almost nothing +- **Determinism** — Part contents are a pure function of the header; the decoder rebuilds each part's block set from `seq` alone, seeded with the exact u32 product `seq × 0x9e3779b1` (`Math.imul` — float multiplication diverges from `seq = 3,393,265`) +- **Wire part** — `air-gap:` + unpadded base64url of a 23-byte big-endian header (`ver` u8 = 1, `sessionId` 8 bytes, `seq` u32, `K` u16, `msgLen` u32, `crc32` u32) and exactly one block +- **Block size off the wire** — Inferred from payload length and pinned per session, so every part is the same size and each application picks its own symbol density up to `MAX_BLOCK_BYTES` (2048) +- **Session identity** — `(sessionId, K, msgLen, crc32)`; the decoder locks on and only switches after `SESSION_SWITCH_PARTS` (3) consecutive parts of one new session +- **Probabilistic recovery** — Distinct parts can be linearly dependent (a pinned K = 3 vector stalls at 1/3 after six distinct parts); receivers keep scanning, senders keep looping - **Fail closed** — A CRC mismatch discards the assembly and resets, so a still-looping sender simply refills the decoder ## When to use this @@ -131,19 +142,23 @@ onCancel(() => decoder.reset()) - Confidentiality or authenticity — CRC32 is an integrity check, not a MAC; encrypt and sign inside the payload - QR rendering or camera capture — bring your own; this package only produces and consumes strings - Interoperating with BRC-225 TKQR1, BC-UR (`ur:`), or the legacy `bsvpayf2:` prefix — none share this wire format +- Production systems that cannot tolerate a wire change — the protocol is **experimental** until a second independent implementation passes the shared vectors ## Spec conformance +- **Wire spec** — [`specs/transport/air-gap-optical.md`](../../specs/air-gap-optical.md) (normative), registered publicly as [BRC-141](https://github.com/bsv-blockchain/BRCs/blob/master/peer-to-peer/0141.md) +- **Conformance vectors** — [`conformance/vectors/transport/air-gap-optical.json`](https://github.com/bsv-blockchain/ts-stack/blob/main/conformance/vectors/transport/air-gap-optical.json), executed by both the package test suite and the cross-language conformance runner; includes seed-precision boundary vectors at `seq = 3,393,265`, `0x7fffffff` and `0xffffffff` - **CRC-32** — IEEE 802.3, polynomial `0xedb88320`, check value `0xCBF43926` for ASCII `123456789` -- **base64url** — RFC 4648 §5, unpadded -- **Conformance vectors** — Frozen part strings in `tests/vectors.test.ts`; any implementation must reproduce them byte for byte -- **BRC** — The wire format is intended for a future BRC; no number is assigned yet +- **base64url** — RFC 4648 §5, unpadded; padding, whitespace and foreign alphabets are rejected +- **Coordination** — Convergence with PiWalletSV `PW1`, Vault Manager `CHUNK` and legacy `bsvpayf2:` (shared optical layer, common wallet-state envelope above it, explicit adapters) is tracked in [ts-stack issue #408](https://github.com/bsv-blockchain/ts-stack/issues/408) ## Common pitfalls - **Waiting for `done` before reading** — `message()` returns `null` until every block is recovered; check `done` first - **Treating `message() === null` as fatal** — After a CRC failure it means "keep scanning"; the decoder has already reset itself +- **Counting `seq` upward forever** — `seq` is a finite u32 and a receiver may stall on linearly dependent mixes; loop `seq` over a few multiples of `blockCount` instead - **Changing `blockBytes` mid-stream** — The decoder pins the first accepted payload length and rejects the rest of the session +- **Sizing symbols against alphanumeric QR capacity** — base64url forces byte mode; use `estimatePartCharLength` against byte-mode tables - **Exporting a display interval from this package** — There is none by design; the application owns its animation loop - **Passing an empty or oversize message** — The encoder throws `AirGapError`; validate before constructing @@ -155,6 +170,7 @@ onCancel(() => decoder.reset()) ## Reference +- [Wire specification (BRC-141)](../../specs/air-gap-optical.md) - [API reference (TypeDoc)](https://bsv-blockchain.github.io/ts-stack/api/air-gap/) - [Source on GitHub](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/air-gap) - [npm](https://www.npmjs.com/package/@bsv/air-gap) diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 47a22b4c0..2a090f29e 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -23,39 +23,39 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.2.1` | `0.2.4` | patch | [API and usage](../packages/middleware/402-pay.md) | No consumer migration is required; client and server exports, payment protocol behavior, and runtime defaults are unchanged. | -| `@bsv/air-gap` | `0.0.0` | `0.1.0` | minor | [API and usage](../packages/helpers/air-gap.md) | No consumer migration is required; this is the first published release of a new package with no prior public API. | -| `@bsv/amountinator` | `2.1.1` | `2.1.4` | patch | [API and usage](../packages/helpers/amountinator.md) | No consumer migration is required; this is a backward-compatible patch candidate. | -| `@bsv/auth` | `0.1.1` | `0.1.3` | patch | [API and usage](../packages/middleware/auth.md) | No consumer migration is required; authentication APIs, wire behavior, and runtime defaults are unchanged. | -| `@bsv/auth-express-middleware` | `2.1.2` | `2.1.5` | patch | [API and usage](../packages/middleware/auth-express-middleware.md) | No consumer migration is required; existing public CORS defaults and middleware APIs are retained. | -| `@bsv/authsocket` | `2.1.1` | `2.1.4` | patch | [API and usage](../packages/messaging/authsocket.md) | No existing behavior changes automatically; service owners can call await server.close() during graceful shutdown. | -| `@bsv/authsocket-client` | `2.1.1` | `2.1.3` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No consumer migration is required; client APIs, authenticated socket behavior, and supported imports are unchanged. | -| `@bsv/btms` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/wallet/btms.md) | No consumer migration is required; token and lookup wire contracts are unchanged. | -| `@bsv/btms-permission-module` | `1.1.1` | `1.1.3` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | No consumer migration is required; permission-module APIs and token semantics are unchanged. | -| `@bsv/did` | `0.2.1` | `0.2.4` | patch | [API and usage](../packages/helpers/did.md) | No consumer migration is required; DID APIs, encodings, credential behavior, and supported import forms are unchanged. | -| `@bsv/did-client` | `1.2.1` | `1.2.3` | patch | [API and usage](../packages/helpers/did-client.md) | No consumer migration is required; DID client APIs, encodings, and supported imports are unchanged. | -| `@bsv/fund-wallet` | `1.4.1` | `1.4.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No consumer migration is required; wallet funding APIs and transaction behavior are unchanged. | -| `@bsv/gasp` | `1.3.1` | `1.3.5` | patch | [API and usage](../packages/overlays/gasp.md) | No consumer migration is required; existing constructor calls, imports, synchronization behavior, and wire semantics are unchanged. | -| `@bsv/message-box-client` | `2.2.2` | `2.2.6` | patch | [API and usage](../packages/messaging/message-box-client.md) | No consumer migration is required; Message Box protocol and client entry points are unchanged. | -| `@bsv/overlay` | `2.2.1` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay.md) | No consumer migration is required; existing imports, submission results, notification contracts, storage order, and network behavior remain unchanged. | -| `@bsv/overlay-discovery-services` | `2.1.1` | `2.1.6` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No consumer migration is required; discovery records and public network behavior are unchanged. | -| `@bsv/overlay-express` | `2.4.2` | `2.4.9` | patch | [API and usage](../packages/overlays/overlay-express.md) | No consumer migration is required; wildcard credential-free public access remains the default and runtimes may opt into the new close method. | -| `@bsv/overlay-topics` | `1.6.1` | `1.6.8` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No consumer migration is required; topic IDs, lookup contracts, and persisted formats are unchanged. | -| `@bsv/paymail` | `2.4.2` | `2.4.5` | patch | [API and usage](../packages/messaging/paymail.md) | No consumer migration is required; existing Paymail client APIs and protocol semantics are retained. | -| `@bsv/payment-express-middleware` | `2.1.1` | `2.1.4` | patch | [API and usage](../packages/middleware/payment-express-middleware.md) | No consumer migration is required; legacy x-bsv-payment JSON behavior remains supported. | -| `@bsv/sdk` | `2.2.0` | `2.2.15` | patch | [API and usage](../packages/sdk/bsv-sdk.md) | No consumer migration is required; the source candidate preserves the 2.x public API, wire encodings, script semantics, errors, and supported import forms. | -| `@bsv/simple` | `0.4.1` | `0.4.8` | patch | [API and usage](../packages/helpers/simple.md) | No consumer migration is required; the browser and server entry points remain compatible. | -| `@bsv/templates` | `1.9.1` | `1.9.6` | patch | [API and usage](../packages/helpers/templates.md) | No consumer migration is required; template APIs, supported imports, and generated script semantics are unchanged. | -| `@bsv/teranode-listener` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/network/teranode-listener.md) | No consumer migration is required; listener APIs, topics, and network configuration are unchanged. | -| `@bsv/verifast` | `0.3.0` | `0.3.4` | patch | [API and usage](../packages/sdk/verifast.md) | No consumer migration is required; exports, verification behavior, worker protocols, package paths, and runtime defaults are unchanged. | -| `@bsv/wallet-helper` | `0.1.1` | `0.1.6` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No consumer migration is required; fluent builder APIs and transaction semantics are unchanged. | -| `@bsv/wallet-relay` | `0.2.2` | `0.3.3` | minor | [API and usage](../packages/wallet/wallet-relay.md) | QRPairingCode now renders a native button and accepts button wrapper attributes. Existing className, style, data, and ARIA props continue to work; update div-specific wrapper selectors or explicitly typed div event handlers. | -| `@bsv/wallet-toolbox` | `2.4.4` | `2.4.20` | patch | [API and usage](../packages/wallet/wallet-toolbox.md) | No consumer migration is required; persisted schemas, provider behavior, transaction construction, monitor behavior, and the 2.x wallet and storage interfaces remain compatible. | -| `@bsv/wallet-toolbox-client` | `2.4.4` | `2.4.20` | patch | [API and usage](../packages/wallet/wallet-toolbox-client.md) | No consumer migration is required; client entry points and remote storage contracts remain compatible. | -| `@bsv/wallet-toolbox-mobile` | `2.4.4` | `2.4.20` | patch | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | No consumer migration is required; React Native and mobile bridge contracts remain compatible. | -| `create-bsv-app` | `1.0.2` | `1.0.4` | patch | [API and usage](../packages/helpers/create-bsv-app.md) | No consumer migration is required; generated application structure and CLI behavior are unchanged. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.2.1` | `0.2.4` | patch | [API and usage](../packages/middleware/402-pay.md) | No consumer migration is required; client and server exports, payment protocol behavior, and runtime defaults are unchanged. | +| `@bsv/air-gap` | `0.0.0` | `0.1.1` | minor | [API and usage](../packages/helpers/air-gap.md) | No consumer migration is required; this is the first published release of a new package with no prior public API. The experimental pre-release framing that circulated on the unmerged feature branch is not accepted by the v1 decoder. | +| `@bsv/amountinator` | `2.1.1` | `2.1.4` | patch | [API and usage](../packages/helpers/amountinator.md) | No consumer migration is required; this is a backward-compatible patch candidate. | +| `@bsv/auth` | `0.1.1` | `0.1.3` | patch | [API and usage](../packages/middleware/auth.md) | No consumer migration is required; authentication APIs, wire behavior, and runtime defaults are unchanged. | +| `@bsv/auth-express-middleware` | `2.1.2` | `2.1.5` | patch | [API and usage](../packages/middleware/auth-express-middleware.md) | No consumer migration is required; existing public CORS defaults and middleware APIs are retained. | +| `@bsv/authsocket` | `2.1.1` | `2.1.4` | patch | [API and usage](../packages/messaging/authsocket.md) | No existing behavior changes automatically; service owners can call await server.close() during graceful shutdown. | +| `@bsv/authsocket-client` | `2.1.1` | `2.1.3` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No consumer migration is required; client APIs, authenticated socket behavior, and supported imports are unchanged. | +| `@bsv/btms` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/wallet/btms.md) | No consumer migration is required; token and lookup wire contracts are unchanged. | +| `@bsv/btms-permission-module` | `1.1.1` | `1.1.3` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | No consumer migration is required; permission-module APIs and token semantics are unchanged. | +| `@bsv/did` | `0.2.1` | `0.2.4` | patch | [API and usage](../packages/helpers/did.md) | No consumer migration is required; DID APIs, encodings, credential behavior, and supported import forms are unchanged. | +| `@bsv/did-client` | `1.2.1` | `1.2.3` | patch | [API and usage](../packages/helpers/did-client.md) | No consumer migration is required; DID client APIs, encodings, and supported imports are unchanged. | +| `@bsv/fund-wallet` | `1.4.1` | `1.4.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No consumer migration is required; wallet funding APIs and transaction behavior are unchanged. | +| `@bsv/gasp` | `1.3.1` | `1.3.5` | patch | [API and usage](../packages/overlays/gasp.md) | No consumer migration is required; existing constructor calls, imports, synchronization behavior, and wire semantics are unchanged. | +| `@bsv/message-box-client` | `2.2.2` | `2.2.6` | patch | [API and usage](../packages/messaging/message-box-client.md) | No consumer migration is required; Message Box protocol and client entry points are unchanged. | +| `@bsv/overlay` | `2.2.1` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay.md) | No consumer migration is required; existing imports, submission results, notification contracts, storage order, and network behavior remain unchanged. | +| `@bsv/overlay-discovery-services` | `2.1.1` | `2.1.6` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No consumer migration is required; discovery records and public network behavior are unchanged. | +| `@bsv/overlay-express` | `2.4.2` | `2.4.9` | patch | [API and usage](../packages/overlays/overlay-express.md) | No consumer migration is required; wildcard credential-free public access remains the default and runtimes may opt into the new close method. | +| `@bsv/overlay-topics` | `1.6.1` | `1.6.8` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No consumer migration is required; topic IDs, lookup contracts, and persisted formats are unchanged. | +| `@bsv/paymail` | `2.4.2` | `2.4.5` | patch | [API and usage](../packages/messaging/paymail.md) | No consumer migration is required; existing Paymail client APIs and protocol semantics are retained. | +| `@bsv/payment-express-middleware` | `2.1.1` | `2.1.4` | patch | [API and usage](../packages/middleware/payment-express-middleware.md) | No consumer migration is required; legacy x-bsv-payment JSON behavior remains supported. | +| `@bsv/sdk` | `2.2.0` | `2.2.15` | patch | [API and usage](../packages/sdk/bsv-sdk.md) | No consumer migration is required; the source candidate preserves the 2.x public API, wire encodings, script semantics, errors, and supported import forms. | +| `@bsv/simple` | `0.4.1` | `0.4.8` | patch | [API and usage](../packages/helpers/simple.md) | No consumer migration is required; the browser and server entry points remain compatible. | +| `@bsv/templates` | `1.9.1` | `1.9.6` | patch | [API and usage](../packages/helpers/templates.md) | No consumer migration is required; template APIs, supported imports, and generated script semantics are unchanged. | +| `@bsv/teranode-listener` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/network/teranode-listener.md) | No consumer migration is required; listener APIs, topics, and network configuration are unchanged. | +| `@bsv/verifast` | `0.3.0` | `0.3.4` | patch | [API and usage](../packages/sdk/verifast.md) | No consumer migration is required; exports, verification behavior, worker protocols, package paths, and runtime defaults are unchanged. | +| `@bsv/wallet-helper` | `0.1.1` | `0.1.6` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No consumer migration is required; fluent builder APIs and transaction semantics are unchanged. | +| `@bsv/wallet-relay` | `0.2.2` | `0.3.3` | minor | [API and usage](../packages/wallet/wallet-relay.md) | QRPairingCode now renders a native button and accepts button wrapper attributes. Existing className, style, data, and ARIA props continue to work; update div-specific wrapper selectors or explicitly typed div event handlers. | +| `@bsv/wallet-toolbox` | `2.4.4` | `2.4.20` | patch | [API and usage](../packages/wallet/wallet-toolbox.md) | No consumer migration is required; persisted schemas, provider behavior, transaction construction, monitor behavior, and the 2.x wallet and storage interfaces remain compatible. | +| `@bsv/wallet-toolbox-client` | `2.4.4` | `2.4.20` | patch | [API and usage](../packages/wallet/wallet-toolbox-client.md) | No consumer migration is required; client entry points and remote storage contracts remain compatible. | +| `@bsv/wallet-toolbox-mobile` | `2.4.4` | `2.4.20` | patch | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | No consumer migration is required; React Native and mobile bridge contracts remain compatible. | +| `create-bsv-app` | `1.0.2` | `1.0.4` | patch | [API and usage](../packages/helpers/create-bsv-app.md) | No consumer migration is required; generated application structure and CLI behavior are unchanged. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -81,8 +81,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/helpers/air-gap.md](../packages/helpers/air-gap.md) - Source: [packages/helpers/air-gap](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/air-gap) -- Release note: Introduces the zero-dependency one-directional optical air-gap transport: fountain-coded wire parts for arbitrary bytes, CRC32 payload integrity, a camera-safe decoder that never throws, and dual CJS/ESM builds. -- Migration: No consumer migration is required; this is the first published release of a new package with no prior public API. +- Release note: Introduces the zero-dependency one-directional optical air-gap transport, wire protocol v1 (BRC-141): a versioned 23-byte header with an 8-byte session identity, exact-integer ideal-soliton fountain coding, a resource-bounded camera-safe decoder with session locking that never throws, CRC32 payload integrity, browser and Node consumers, and shared conformance vectors under conformance/vectors/transport/. +- Migration: No consumer migration is required; this is the first published release of a new package with no prior public API. The experimental pre-release framing that circulated on the unmerged feature branch is not accepted by the v1 decoder. | Public subpath | Runtime target(s) | Declaration target(s) | | -------------- | ---------------------------------------- | -------------------------------------------- | diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index 94f981b6b..1eaf48bd3 100644 --- a/docs/reference/stack-facts.md +++ b/docs/reference/stack-facts.md @@ -37,7 +37,7 @@ authorized release action. | Area | Package | Source version | Project profile | Consumer profiles | Runtime targets | Node engine | Source | | --- | --- | --- | --- | --- | --- | --- | --- | -| helpers | `@bsv/air-gap` | `0.1.0` | node-library | node-cjs, node-esm | node | `>=22` | [packages/helpers/air-gap](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/air-gap) | +| helpers | `@bsv/air-gap` | `0.1.1` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/air-gap](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/air-gap) | | helpers | `@bsv/amountinator` | `2.1.4` | node-library | node-cjs, node-esm | node | `>=22` | [packages/helpers/amountinator](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/amountinator) | | helpers | `@bsv/did` | `0.2.4` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/did](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did) | | helpers | `@bsv/did-client` | `1.2.3` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/helpers/did-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/did-client) | @@ -103,14 +103,14 @@ recorded container release route; they are not published by the public-package j | Metric | Current value | | --- | --- | -| Vector files | 74 | -| Vectors | 6650 | -| Structurally passed | 6439 | +| Vector files | 75 | +| Vectors | 6681 | +| Structurally passed | 6470 | | Governed skips | 211 | -| Required parity vectors | 6446 | +| Required parity vectors | 6477 | | Intended parity vectors | 204 | | Explicitly skipped vector entries | 7 | -| Corpus metadata revision | 2026-07-27 | +| Corpus metadata revision | 2026-07-30 | Structural runner pass/skip results and parity classifications answer different questions: the former is the current runner outcome, while the latter records cross-language diff --git a/docs/specs/air-gap-optical.md b/docs/specs/air-gap-optical.md new file mode 100644 index 000000000..f7daa1dc4 --- /dev/null +++ b/docs/specs/air-gap-optical.md @@ -0,0 +1,54 @@ +--- +id: spec-air-gap-optical +title: Air-Gap Optical Transport (BRC-141) +kind: spec +version: '1.0.0' +last_updated: '2026-07-30' +last_verified: '2026-07-30' +review_cadence_days: 30 +status: experimental +tags: ['spec', 'transport', 'air-gap', 'qr', 'optical'] +--- + +# Air-Gap Optical Transport (BRC-141) + +> A one-directional, payload-agnostic transport that carries arbitrary bytes across an optical air gap: a sender renders fountain-coded text parts (typically as animated QR codes) and a receiver reassembles the exact bytes from a camera feed, with no back-channel of any kind. Wire protocol v1. + +## At a glance + +| Field | Value | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Format | Markdown wire spec ([`specs/transport/air-gap-optical.md`](https://github.com/bsv-blockchain/ts-stack/blob/main/specs/transport/air-gap-optical.md)) | +| Version | 1.0.0 (wire `ver` byte = 1) | +| Status | experimental — no independent second implementation has exercised the shared vectors yet | +| BRC | [BRC-141](https://github.com/bsv-blockchain/BRCs/blob/master/peer-to-peer/0141.md) | +| Implementations | [@bsv/air-gap](../packages/helpers/air-gap.md) (reference) | +| Conformance | [`conformance/vectors/transport/air-gap-optical.json`](https://github.com/bsv-blockchain/ts-stack/blob/main/conformance/vectors/transport/air-gap-optical.json) | + +## What problem this solves + +**Moving bytes between devices with no shared network.** An air-gapped signer, an offline vault, or two phones with no connectivity can only communicate through a screen and a camera. A single QR code caps out far below realistic payload sizes, so the payload must be split across an animated sequence — and a camera _will_ miss frames. + +**Missed frames without a retry protocol.** Because parts are fountain-coded (Luby transform with a systematic prefix) rather than index-numbered chunks, the receiver does not wait for any specific frame to come around again: it uses whatever distinct parts arrive, in any order. + +**A shared wire format.** BRC-225 (TKQR1), BC-UR, `bsvpayf2:`, PiWalletSV `PW1` and Vault Manager `CHUNK` all solve this problem with mutually incompatible framings. BRC-141 is the versioned, conformance-fixed transport intended as the convergence point, while remaining strictly payload-agnostic. + +## Wire format summary + +``` +air-gap: + base64url( ver u8 ‖ sessionId 8B ‖ seq u32 ‖ K u16 ‖ msgLen u32 ‖ crc32 u32 ‖ block ) +``` + +Header is 23 bytes, big-endian, `ver = 1`. Parts `0..K-1` carry the source blocks verbatim (one clean camera cycle decodes with zero overhead); later parts are deterministic XOR mixes reconstructed from `seq` alone via xorshift32 (`Math.imul` u32 seeding) and an exact-integer ideal-soliton degree draw. Recovery from `K + ε` distinct parts is probabilistic, not guaranteed — receivers keep scanning and senders keep looping. The full normative text, including decoder resource bounds, session locking, and the seed-precision boundary vectors, lives in [`specs/transport/air-gap-optical.md`](https://github.com/bsv-blockchain/ts-stack/blob/main/specs/transport/air-gap-optical.md). + +## Key decoder guarantees + +- `accept()` is total: hostile camera input never throws and oversize strings are rejected before any allocation +- No partial or unverified bytes escape; output is CRC-gated and a mismatch discards the assembly +- The decoder locks onto one session; a single stray frame cannot erase progress, and three consecutive frames of a new session switch to it +- Memory and work are bounded against a hostile sender (tracked-seq, pending-part and pending-index budgets) + +## Related + +- [@bsv/air-gap package documentation](../packages/helpers/air-gap.md) +- [BRC-225 TKQR1](https://github.com/bsv-blockchain/BRCs/blob/master/peer-to-peer/0225.md) — indexed-chunk peer alternative, no shared framing diff --git a/docs/specs/index.md b/docs/specs/index.md index 460acf785..71292d14f 100644 --- a/docs/specs/index.md +++ b/docs/specs/index.md @@ -18,20 +18,21 @@ This section documents the protocols and standards that the ts-stack implements. ## Quick Reference -| Spec | Format | Version | Implementations | Purpose | -| ----------------------------------------------- | ------------ | ------- | --------------------------------------------- | -------------------------------------------------------- | -| [BRC-100 Wallet](./brc-100-wallet.md) | JSON Schema | 1.0.0 | @bsv/wallet-toolbox, @bsv/sdk | Standard wallet interface for signing and key management | -| [BRC-31 Auth](./brc-31-auth.md) | AsyncAPI 3.0 | 1.0.0 | @bsv/auth-express-middleware, @bsv/authsocket | Mutual authentication handshake (BRC-103 + BRC-104) | -| [BRC-29 Peer Payment](./brc-29-peer-payment.md) | AsyncAPI 3.0 | 1.0.0 | @bsv/paymail, @bsv/message-box-client | P2P payment derivation and transmission | -| [BRC-121 / 402](./brc-121-402.md) | OpenAPI 3.1 | 1.0.0 | @bsv/402-pay | HTTP micropayment protocol | -| [Overlay HTTP](./overlay-http.md) | OpenAPI 3.1 | 1.0.0 | @bsv/overlay, @bsv/overlay-express | Transaction routing and topic management | -| [Message Box HTTP](./message-box-http.md) | OpenAPI 3.1 | 1.0.0 | @bsv/message-box-client | Store-and-forward messaging API | -| [AuthSocket](./authsocket.md) | AsyncAPI 3.0 | 1.0.0 | @bsv/authsocket | Authenticated WebSocket for live messaging | -| [ARC Broadcast](./arc-broadcast.md) | OpenAPI 3.1 | 1.0.0 | @bsv/sdk | Miner-facing transaction broadcast | -| [Merkle Service](./merkle-service.md) | OpenAPI 3.1 | 1.0.0 | @bsv/sdk | SPV proof delivery service | -| [Storage Adapter](./storage-adapter.md) | OpenAPI 3.1 | 1.0.0 | @bsv/wallet-toolbox | Remote wallet storage interface | -| [GASP Sync](./gasp-sync.md) | AsyncAPI 3.0 | 1.0.0 | @bsv/gasp | Transaction graph synchronization | -| [UHRP](./uhrp.md) | OpenAPI 3.1 | 1.0.0 | @bsv/overlay-topics | Content-addressed file storage | +| Spec | Format | Version | Implementations | Purpose | +| ------------------------------------------------- | ------------------ | ------- | --------------------------------------------- | -------------------------------------------------------- | +| [BRC-100 Wallet](./brc-100-wallet.md) | JSON Schema | 1.0.0 | @bsv/wallet-toolbox, @bsv/sdk | Standard wallet interface for signing and key management | +| [BRC-31 Auth](./brc-31-auth.md) | AsyncAPI 3.0 | 1.0.0 | @bsv/auth-express-middleware, @bsv/authsocket | Mutual authentication handshake (BRC-103 + BRC-104) | +| [BRC-29 Peer Payment](./brc-29-peer-payment.md) | AsyncAPI 3.0 | 1.0.0 | @bsv/paymail, @bsv/message-box-client | P2P payment derivation and transmission | +| [BRC-121 / 402](./brc-121-402.md) | OpenAPI 3.1 | 1.0.0 | @bsv/402-pay | HTTP micropayment protocol | +| [Overlay HTTP](./overlay-http.md) | OpenAPI 3.1 | 1.0.0 | @bsv/overlay, @bsv/overlay-express | Transaction routing and topic management | +| [Message Box HTTP](./message-box-http.md) | OpenAPI 3.1 | 1.0.0 | @bsv/message-box-client | Store-and-forward messaging API | +| [AuthSocket](./authsocket.md) | AsyncAPI 3.0 | 1.0.0 | @bsv/authsocket | Authenticated WebSocket for live messaging | +| [ARC Broadcast](./arc-broadcast.md) | OpenAPI 3.1 | 1.0.0 | @bsv/sdk | Miner-facing transaction broadcast | +| [Merkle Service](./merkle-service.md) | OpenAPI 3.1 | 1.0.0 | @bsv/sdk | SPV proof delivery service | +| [Storage Adapter](./storage-adapter.md) | OpenAPI 3.1 | 1.0.0 | @bsv/wallet-toolbox | Remote wallet storage interface | +| [GASP Sync](./gasp-sync.md) | AsyncAPI 3.0 | 1.0.0 | @bsv/gasp | Transaction graph synchronization | +| [UHRP](./uhrp.md) | OpenAPI 3.1 | 1.0.0 | @bsv/overlay-topics | Content-addressed file storage | +| [Air-Gap Optical (BRC-141)](./air-gap-optical.md) | Markdown wire spec | 1.0.0 | @bsv/air-gap | One-directional optical air-gap transport (experimental) | ## About BRCs diff --git a/governance/browser-artifact-policy.json b/governance/browser-artifact-policy.json index 1f797d80e..2cd7c75f0 100644 --- a/governance/browser-artifact-policy.json +++ b/governance/browser-artifact-policy.json @@ -5,6 +5,13 @@ "reportRetentionDays": 30, "growthPolicy": "Every browser consumer is measured from its exact packed dependency graph with Vite and esbuild (or the governed platform equivalent). A budget increase requires a versioned source change, composition evidence, and explicit review; generated reports preserve package/module composition for comparison.", "packages": [ + { + "name": "@bsv/air-gap", + "path": "packages/helpers/air-gap", + "budget": "packages/helpers/air-gap/browser-budget.json", + "entry": ".", + "splittingDisposition": "Single self-contained codec entry with zero runtime dependencies; no server adapter or optional platform code exists to split." + }, { "name": "@bsv/did-client", "path": "packages/helpers/did-client", diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index e7b8bd45f..2bce453c8 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -14,8 +14,8 @@ "name": "@bsv/air-gap", "publishedVersion": "0.0.0", "releaseType": "minor", - "summary": "Introduces the zero-dependency one-directional optical air-gap transport: fountain-coded wire parts for arbitrary bytes, CRC32 payload integrity, a camera-safe decoder that never throws, and dual CJS/ESM builds.", - "migration": "No consumer migration is required; this is the first published release of a new package with no prior public API." + "summary": "Introduces the zero-dependency one-directional optical air-gap transport, wire protocol v1 (BRC-141): a versioned 23-byte header with an 8-byte session identity, exact-integer ideal-soliton fountain coding, a resource-bounded camera-safe decoder with session locking that never throws, CRC32 payload integrity, browser and Node consumers, and shared conformance vectors under conformance/vectors/transport/.", + "migration": "No consumer migration is required; this is the first published release of a new package with no prior public API. The experimental pre-release framing that circulated on the unmerged feature branch is not accepted by the v1 decoder." }, { "name": "@bsv/amountinator", diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index 6125674eb..ca3001428 100644 --- a/governance/repository-health/baselines.json +++ b/governance/repository-health/baselines.json @@ -14,10 +14,10 @@ "run": "https://github.com/BSV-blockchain/ts-stack/actions/runs/30144812565" }, "conformance": { - "passed": 6439, + "passed": 6470, "skipped": 211, - "total": 6650, - "vectorFiles": 74, + "total": 6681, + "vectorFiles": 75, "run": "https://github.com/BSV-blockchain/ts-stack/actions/runs/30144812559" }, "testExceptions": { @@ -297,7 +297,7 @@ ] }, "publicPackageVersions": { - "@bsv/air-gap": "0.1.0", + "@bsv/air-gap": "0.1.1", "@bsv/amountinator": "2.1.4", "@bsv/wallet-helper": "0.1.6", "create-bsv-app": "1.0.4", diff --git a/governance/repository-health/projects.json b/governance/repository-health/projects.json index 2e92540c5..548bf51ce 100644 --- a/governance/repository-health/projects.json +++ b/governance/repository-health/projects.json @@ -362,10 +362,10 @@ "name": "@bsv/air-gap", "owner": "ts-stack-maintainers", "area": "helpers", - "profile": "node-library", - "consumerProfiles": ["node-cjs", "node-esm"], - "criticality": "tier-2", - "runtimeTargets": ["node"], + "profile": "browser-library", + "consumerProfiles": ["browser-bundler", "browser-esm", "node-cjs", "node-esm"], + "criticality": "tier-1", + "runtimeTargets": ["browser", "node"], "release": "npm-oidc" }, { diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index 51b8b7457..93e89448d 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -245,7 +245,8 @@ "Arbitrary repeating frame-loss patterns still reconstruct the exact payload.", "Arbitrary scanned text never throws and never completes a message.", "A corrupted part yields either the exact original bytes or nothing at all.", - "Every rendered part has the exact predicted character length for its block size." + "Every rendered part has the exact predicted character length for its block size.", + "A single foreign-session frame never erases decoding progress." ] }, { diff --git a/packages/helpers/air-gap/README.md b/packages/helpers/air-gap/README.md index 230f8f747..7918615dd 100644 --- a/packages/helpers/air-gap/README.md +++ b/packages/helpers/air-gap/README.md @@ -3,7 +3,7 @@ [![npm version](https://img.shields.io/npm/v/@bsv/air-gap)](https://www.npmjs.com/package/@bsv/air-gap) [![npm downloads](https://img.shields.io/npm/dm/@bsv/air-gap)](https://www.npmjs.com/package/@bsv/air-gap) -One-directional optical air-gap transport for arbitrary bytes. `@bsv/air-gap` turns a byte array into an endless, deterministic sequence of fountain-coded parts to display as QR codes, and reassembles the original bytes from a camera feed with no back-channel of any kind. Because the parts are fountain-coded rather than numbered chunks, any `K + ε` distinct parts reconstruct the message: a receiver that misses frames — and at several frames per second it will — simply keeps watching instead of waiting for one specific index to come round again. The package is payload-agnostic and has zero runtime dependencies; what the bytes mean, how they are rendered, and how fast they are shown are all decisions for the layer above. +One-directional optical air-gap transport for arbitrary bytes — the reference implementation of the **experimental** [BRC-141](https://github.com/bsv-blockchain/BRCs/blob/master/peer-to-peer/0141.md) wire protocol, v1. `@bsv/air-gap` turns a byte array into a deterministic sequence of fountain-coded parts to display as QR codes, and reassembles the original bytes from a camera feed with no back-channel of any kind. Because the parts are fountain-coded rather than numbered chunks, a receiver that misses frames — and at several frames per second it will — simply keeps watching instead of waiting for one specific index to come round again: with high probability, `K + ε` distinct parts reconstruct the message, and the looping systematic prefix makes eventual recovery certain. The package is payload-agnostic and has zero runtime dependencies; what the bytes mean, how they are rendered, and how fast they are shown are all decisions for the layer above. ## Install @@ -24,12 +24,14 @@ const encoder = new AirGapEncoder(payloadBytes) // The encoder holds no cursor — you own the sequence number and the cadence. let seq = 0 +const cycle = encoder.blockCount * 64 const timer = setInterval(() => { - renderQrCode(encoder.partAt(seq++)) // e.g. 5 frames per second + renderQrCode(encoder.partAt(seq)) // e.g. 5 frames per second + seq = (seq + 1) % cycle // loop: re-running the systematic prefix guarantees recovery }, 200) ``` -`partAt(seq)` is a pure function of `(message, blockBytes, seq)`, so `seq` may grow without bound and the same part can be re-rendered as often as needed. A single-block message needs no animation at all: display `partAt(0)` and never advance `seq`. +`partAt(seq)` is a pure function of `(message, blockBytes, sessionId, seq)`, so the same part can be re-rendered as often as needed. A single-block message needs no animation at all: display `partAt(0)` and never advance `seq`. The sequence number is a u32 — finite, which is one more reason to loop rather than count upward forever. ### Receiving: accept scans @@ -48,60 +50,72 @@ onBarcodeScan(text => { }) ``` -`accept` never throws and never emits a partial message — a camera hands it stray reads, other people's QR codes and half-decoded frames, and every one of those is an ordinary `{ ok: false }` that changes nothing. `message()` returns the payload only once every block is recovered *and* the CRC-32 matches; on a mismatch it discards the assembly and resets itself, so a still-looping sender refills it without the application having to manage a retry. +`accept` never throws and never emits a partial message — a camera hands it stray reads, other people's QR codes and half-decoded frames, and every one of those is an ordinary `{ ok: false }` that changes nothing. Oversize strings are rejected before any decoding work, and the decoder's memory is bounded no matter what the camera feeds in. `message()` returns the payload only once every block is recovered _and_ the CRC-32 matches; on a mismatch it discards the assembly and resets itself, so a still-looping sender refills it without the application having to manage a retry. -## Wire format +## Wire format (BRC-141, v1) -A part is the prefix followed by unpadded base64url of a fixed 14-byte big-endian header and exactly one block: +A part is the prefix followed by unpadded base64url of a fixed 23-byte big-endian header and exactly one block: ``` -air-gap: + base64url( seq ‖ K ‖ msgLen ‖ crc32 ‖ block ) +air-gap: + base64url( ver ‖ sessionId ‖ seq ‖ K ‖ msgLen ‖ crc32 ‖ block ) ``` -| Field | Size | Meaning | -|-------|------|---------| -| `seq` | u32 | Part sequence number, unbounded. `seq < K` is source block `seq` verbatim (the systematic prefix). | -| `K` | u16 | Source block count, `ceil(msgLen / blockBytes)`. | -| `msgLen` | u32 | Length of the whole payload in bytes. | -| `crc32` | u32 | IEEE CRC-32 of the whole original payload, and of nothing else. | -| `block` | `blockBytes` | One source block, or an XOR mix of several. The last source block is zero-padded. | +| Field | Size | Meaning | +| ----------- | ------------ | --------------------------------------------------------------------------------------------------------- | +| `ver` | u8 | Wire protocol version, `1`. Any other value is rejected. | +| `sessionId` | 8 bytes | Names this encoder's stream. Random by default; explicit for deterministic vectors or session resumption. | +| `seq` | u32 | Part sequence number. `seq < K` is source block `seq` verbatim (the systematic prefix). | +| `K` | u16 | Source block count, `ceil(msgLen / blockBytes)`. | +| `msgLen` | u32 | Length of the whole payload in bytes. | +| `crc32` | u32 | IEEE CRC-32 of the whole original payload, and of nothing else. Integrity only — never authenticity. | +| `block` | `blockBytes` | One source block, or an XOR mix of several. The last source block is zero-padded. | -The block size is deliberately **not** on the wire: the decoder infers it from the payload length, which keeps every part exactly the same size and lets each application pick its own symbol density. Three consequences worth knowing: +The block size is deliberately **not** on the wire: the decoder infers it from the payload length (and pins it per session), which keeps every part exactly the same size and lets each application pick its own symbol density up to `MAX_BLOCK_BYTES` (2,048). Points worth knowing: -- **Session identity** is `(K, msgLen, crc32)`. A part with different values is a different message, and adopting it resets the decoder. -- **The block size is pinned** by the first part accepted into a session. Later parts that disagree are rejected, which is what stops two senders — or one mangled frame — from being assembled together. -- **Determinism is the contract.** The mixes are chosen by an xorshift32 RNG seeded from `seq` with an ideal-soliton degree, so a decoder rebuilds each part's block set from `seq` alone. `tests/vectors.test.ts` freezes the exact strings any implementation must reproduce. +- **Session identity** is `(sessionId, K, msgLen, crc32)`. The decoder locks onto the first session it accepts: one stray frame from another sender is rejected, and only `SESSION_SWITCH_PARTS` (3) consecutive parts of the same new session switch it over. +- **The block size is pinned** by the first part accepted into a session. Later parts that disagree are rejected, which is what stops one padded or truncated frame from being assembled with honest parts. +- **Determinism is the contract.** Mixes are chosen by an xorshift32 RNG seeded with the exact u32 product `seq × 0x9e3779b1` (`Math.imul`, never float multiplication) and an exact-integer ideal-soliton degree draw, so a decoder rebuilds each part's block set from `seq` alone. The frozen strings any implementation must reproduce live in the repository-root corpus at `conformance/vectors/transport/air-gap-optical.json`, including seeds on both sides of the JavaScript float-precision boundary. +- **Recovery is probabilistic, not absolute.** Distinct parts can be linearly dependent (for `K = 3`, parts `4, 27, 38, 56, 63, 72` all reduce to block 0 — pinned as a regression vector). Median cost for a receiver that missed the whole systematic pass is ≈1.4–1.5 K parts, the 99th percentile ≈4–4.6 K; a sender that loops its sequence bounds the worst case by the next systematic pass. See §6 of the [wire spec](https://github.com/bsv-blockchain/ts-stack/blob/main/specs/transport/air-gap-optical.md). ## API -| Export | Purpose | -|--------|---------| -| `AirGapEncoder` | `new AirGapEncoder(message, blockBytes?)`; `partAt(seq)` renders a part string. Read-only `blockCount`, `blockBytes`, `messageLength`. | -| `AirGapDecoder` | `accept(text)` feeds one scan and returns `{ ok, done, have, total }`; `message()` returns the verified payload or `null`; `reset()` abandons the current scan. | -| `AirGapProgress` | Type of what `accept` returns. | -| `crc32(bytes)` | IEEE CRC-32 as an unsigned 32-bit number. | -| `isAirGapPart(text)` | Cheap prefix test, for routing camera reads before decoding them. | -| `estimatePartCharLength(blockBytes?)` | Exact character length of every part for a block size, for sizing against a QR version's capacity. | -| `AirGapError` | Thrown by the encoder on a message or configuration it cannot send. The decoder never throws. | -| `AIR_GAP_PREFIX` | `'air-gap:'` | -| `DEFAULT_BLOCK_BYTES` | `1200` | -| `MAX_MESSAGE_BYTES` | `65536` | +| Export | Purpose | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `AirGapEncoder` | `new AirGapEncoder(message, { blockBytes?, sessionId? })`; `partAt(seq)` renders a part string. Read-only `blockCount`, `blockBytes`, `messageLength`, `sessionId`. | +| `AirGapDecoder` | `accept(text)` feeds one scan and returns `{ ok, done, have, total }`; `message()` returns the verified payload or `null`; `reset()` abandons the current scan. | +| `AirGapProgress` / `AirGapEncoderOptions` | The types of what `accept` returns and what the encoder constructor takes. | +| `crc32(bytes)` | IEEE CRC-32 as an unsigned 32-bit number. | +| `isAirGapPart(text)` | Cheap prefix test, for routing camera reads before decoding them. | +| `estimatePartCharLength(blockBytes?)` | Exact character length of every part for a block size, for sizing against a QR version's **byte-mode** capacity. | +| `AirGapError` | Thrown by the encoder on a message or configuration it cannot send. The decoder never throws. | +| `AIR_GAP_PREFIX` | `'air-gap:'` | +| `AIR_GAP_WIRE_VERSION` | `1` | +| `SESSION_ID_BYTES` | `8` | +| `SESSION_SWITCH_PARTS` | `3` — consecutive foreign parts that switch the decoder to a new session | +| `DEFAULT_BLOCK_BYTES` | `1200` | +| `MAX_BLOCK_BYTES` | `2048` | +| `MAX_MESSAGE_BYTES` | `65536` | ## Defaults and tunables -`DEFAULT_BLOCK_BYTES` is 1,200, which renders as a 1,627-character part — inside a version-40 QR symbol with margin for a camera that is not square-on to the screen. Lower it for smaller, more forgiving symbols at the cost of more parts; raise it only if the receiving camera really can resolve the density. +`DEFAULT_BLOCK_BYTES` is 1,200, which renders as a 1,639-character part. base64url text contains lowercase letters, `-` and `_`, so QR encoders store parts in **byte mode** (one symbol byte per character), never the smaller alphanumeric mode: 1,639 bytes fits a version-40 QR symbol at every error-correction level up to Q (1,663) and leaves 44% headroom at level L (2,953) for a camera that is not square-on to the screen. Lower it for smaller, more forgiving symbols at the cost of more parts; raise it — up to `MAX_BLOCK_BYTES`, the largest block whose part still fits version 40-L — only if the receiving camera really can resolve the density. `estimatePartCharLength` gives the exact part length for any block size before an encoder is built. `MAX_MESSAGE_BYTES` is 65,536. At five parts per second and the default block size that is already 15 to 30 seconds of two people holding phones together, which is the practical limit of the medium; a larger payload is a sign the layer above should send a reference instead of the bytes. Display cadence is not this package's concern and is not configurable here — no frame interval is exported. The application owns its own animation loop. +## Hostile-input bounds + +The decoder is the untrusted surface of this package, and its resources are bounded regardless of what a camera — or a hostile sender — feeds it: scanned strings longer than any legal part are rejected before base64 decoding, duplicate tracking is capped (`65,536` sequence numbers), and buffered mixes are budgeted (`1,024` parts / `4,096` unresolved block references). Systematic and degree-1 parts are never buffered, so a looping honest sender always completes even against a full buffer. The CRC is an integrity check only: an adversary who can show codes to your camera can forge any header, so **authenticate the payload inside the payload** (signature or MAC) whenever it matters. + ## Prior art Not wire-compatible with any of these; listed because they solve the same problem: - **BRC-225 TKQR1** — fixed-order indexed chunks; a peer alternative with no shared framing. - **BC-UR** (`ur:`) — fountain-coded QR for crypto air-gaps; different framing and coding. -- **bsv-browser `fountain.ts`** (`bsvpayf2:`) — the direct algorithm ancestor of this package, from which the coding is ported bit-for-bit. This is its payload-agnostic evolution; the legacy prefix is not accepted. +- **bsv-browser `fountain.ts`** (`bsvpayf2:`) — the direct algorithm ancestor of this package. Its coding contained a JavaScript float-precision seed bug and a mis-sampled degree distribution, which BRC-141 v1 deliberately corrects rather than reproduces; neither its prefix nor its parts are accepted. +- **PiWalletSV `PW1`**, **Vault Manager `CHUNK`** — indexed transports in adjacent wallet projects; the convergence and adapter plan is tracked in the coordination issue linked from the [package documentation](https://github.com/bsv-blockchain/ts-stack/blob/main/docs/packages/helpers/air-gap.md). ## Non-goals diff --git a/packages/helpers/air-gap/browser-budget.json b/packages/helpers/air-gap/browser-budget.json new file mode 100644 index 000000000..edea261d0 --- /dev/null +++ b/packages/helpers/air-gap/browser-budget.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "profile": "browser", + "package": "@bsv/air-gap", + "entry": ".", + "requiredExports": [ + "AIR_GAP_PREFIX", + "AIR_GAP_WIRE_VERSION", + "AirGapDecoder", + "AirGapEncoder", + "AirGapError", + "DEFAULT_BLOCK_BYTES", + "MAX_BLOCK_BYTES", + "MAX_MESSAGE_BYTES", + "SESSION_ID_BYTES", + "SESSION_SWITCH_PARTS", + "crc32", + "estimatePartCharLength", + "isAirGapPart" + ], + "prohibitedExports": [], + "maximumBytes": { + "vite": { "raw": 18000, "gzip": 6500, "brotli": 5500 }, + "esbuild": { "raw": 18000, "gzip": 6500, "brotli": 5500 } + } +} diff --git a/packages/helpers/air-gap/package.json b/packages/helpers/air-gap/package.json index ca65112aa..2b5b2c23b 100644 --- a/packages/helpers/air-gap/package.json +++ b/packages/helpers/air-gap/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/air-gap", - "version": "0.1.0", + "version": "0.1.1", "sideEffects": false, "engines": { "node": ">=22" @@ -43,7 +43,8 @@ "build": "tsdown src/index.ts --format cjs,esm --dts --sourcemap --clean --out-dir dist --tsconfig tsconfig.build.json", "format:check": "pnpm --workspace-root exec prettier --check \"packages/helpers/air-gap/{src,tests}/**/*.ts\" \"packages/helpers/air-gap/*.{cjs,json,ts}\"", "lint": "oxlint src tests --deny-warnings", - "pack:check": "node ../../../scripts/check-package-artifact.mjs . --exports AIR_GAP_PREFIX,DEFAULT_BLOCK_BYTES,MAX_MESSAGE_BYTES,AirGapError,crc32,AirGapEncoder,AirGapDecoder,isAirGapPart,estimatePartCharLength", + "pack:check": "node ../../../scripts/check-package-artifact.mjs . --exports AIR_GAP_PREFIX,AIR_GAP_WIRE_VERSION,DEFAULT_BLOCK_BYTES,MAX_BLOCK_BYTES,MAX_MESSAGE_BYTES,SESSION_ID_BYTES,SESSION_SWITCH_PARTS,AirGapError,crc32,AirGapEncoder,AirGapDecoder,isAirGapPart,estimatePartCharLength", + "test:browser": "pnpm build && node ../../../scripts/check-browser-package.mjs .", "typecheck": "tsc --project tsconfig.typecheck.json" }, "repository": { diff --git a/packages/helpers/air-gap/src/base64url.ts b/packages/helpers/air-gap/src/base64url.ts index 06ede5f60..227dac486 100644 --- a/packages/helpers/air-gap/src/base64url.ts +++ b/packages/helpers/air-gap/src/base64url.ts @@ -1,10 +1,13 @@ /** * Unpadded base64url, kept local so this package has no runtime dependencies. * - * base64url rather than base64 because a part string ends up in QR alphanumeric - * mode, URLs and log lines, and `+` / `/` / `=` are hostile in all three. - * Encoding runs through `globalThis.btoa` / `atob`, which exist in browsers and - * in Node 22+, so the same code path serves both. + * base64url rather than base64 because a part string ends up in URLs, log + * lines and deep links, and `+` / `/` / `=` are hostile in all three. Note + * that QR encoders store either alphabet in **byte mode** — base64url's + * lowercase letters and `_` rule out the smaller alphanumeric mode — so the + * choice costs nothing in symbol capacity. Encoding runs through + * `globalThis.btoa` / `atob`, which exist in browsers and in Node 22+, so the + * same code path serves both. */ /** diff --git a/packages/helpers/air-gap/src/coding.ts b/packages/helpers/air-gap/src/coding.ts index f927bf90b..26290c182 100644 --- a/packages/helpers/air-gap/src/coding.ts +++ b/packages/helpers/air-gap/src/coding.ts @@ -6,6 +6,12 @@ * RNG over the same seed. Change a constant here and every frozen conformance * vector — and every peer implementation — stops interoperating. Treat this * file as frozen wire format, not as code to improve. + * + * Every operation below is exact in 64-bit floating point and is specified in + * plain integer arithmetic, so a port to any language with 32-bit integers and + * 64-bit integer (or double) multiplication reproduces it bit for bit. The + * normative statement of this mapping is §5 of + * `specs/transport/air-gap-optical.md` (BRC-141). */ /** @@ -27,34 +33,46 @@ function makeRng(seed: number): () => number { } } +/** + * One 23-bit draw from `rng`, in `[0, 2^23)`. + * + * 23 bits so every downstream product stays inside the 53-bit exact-integer + * range of a double: `r * (k - i)` is at most `(2^23 - 1) * 65535 < 2^40`. + */ +function draw23(rng: () => number): number { + return rng() >>> 9 +} + /** * The source-block indices XORed into part `seq`. * * Only meaningful for `seq >= k`; below `k` the part *is* block `seq` (the * systematic prefix, so one clean camera cycle decodes with zero overhead). * - * The degree follows the ideal soliton distribution — 1 with probability 1/K, - * otherwise `d` with probability 1/(d(d-1)) via the `ceil(1/u)` inverse-CDF - * trick — which is what makes any K+ε distinct parts enough to peel out all K - * blocks. Indices come from a partial Fisher–Yates shuffle, so they are - * distinct without a rejection loop. + * The degree is drawn from the ideal soliton distribution over `1..k` — + * ρ(1) = 1/K, ρ(d) = 1/(d(d−1)) — by exact integer inverse-CDF: one 23-bit + * draw `r` gives `d = ceil(2^23 / (r + 1))`, computed as + * `floor((2^23 + r) / (r + 1))`, and any `d > k` (the truncated tail, total + * probability ≈ 1/K) becomes degree 1, which is precisely the mass ρ(1) needs. + * Indices then come from a partial Fisher–Yates shuffle, so they are distinct + * without a rejection loop, with `j = i + floor(r_i * (k - i) / 2^23)` per + * swap — exact integer arithmetic throughout. + * + * The seed is the 32-bit modular product `seq * 0x9e3779b1` (`Math.imul`, not + * `*`: JavaScript number multiplication loses low bits past 2^53, and those + * are exactly the bits a u32 port keeps). * * @param seq - Part sequence number. * @param k - Source block count. */ export function blocksForPart(seq: number, k: number): number[] { - const rng = makeRng((seq * 0x9e3779b1) >>> 0) - // (0,1] for the degree draw — the +1 keeps 1/u finite. - const open01 = () => ((rng() >>> 9) + 1) / 2 ** 23 - // [0,1) for index draws — floor stays in range. - const half01 = () => (rng() >>> 9) / 2 ** 23 - let degree: number - if (k === 1) degree = 1 - else if (open01() <= 1 / k) degree = 1 - else degree = Math.min(k, Math.ceil(1 / open01())) + const rng = makeRng(Math.imul(seq, 0x9e3779b1) >>> 0) + const r = draw23(rng) + let degree = Math.floor((2 ** 23 + r) / (r + 1)) + if (degree > k) degree = 1 const pool = Array.from({ length: k }, (_, i) => i) for (let i = 0; i < degree; i++) { - const j = i + Math.floor(half01() * (k - i)) + const j = i + Math.floor((draw23(rng) * (k - i)) / 2 ** 23) const t = pool[i] pool[i] = pool[j] pool[j] = t diff --git a/packages/helpers/air-gap/src/constants.ts b/packages/helpers/air-gap/src/constants.ts index fdddd580c..b30244014 100644 --- a/packages/helpers/air-gap/src/constants.ts +++ b/packages/helpers/air-gap/src/constants.ts @@ -1,26 +1,64 @@ /** - * Wire constants for the air-gap transport. + * Wire constants for the air-gap transport, protocol version 1. * * Everything here is part of the wire contract except `DEFAULT_BLOCK_BYTES`, * which is only a sensible starting point: the block size is *not* carried in * the header, so each application is free to trade symbol density against part - * count. Deliberately absent is any notion of display cadence — how fast parts - * are rendered is the application's business, not the transport's. + * count within the `MAX_BLOCK_BYTES` ceiling. Deliberately absent is any notion + * of display cadence — how fast parts are rendered is the application's + * business, not the transport's. + * + * The normative definition of these values is `specs/transport/air-gap-optical.md` + * (BRC-141); the implementation-neutral fixtures live under + * `conformance/vectors/transport/`. */ /** ASCII prefix every wire part starts with. */ export const AIR_GAP_PREFIX = 'air-gap:' +/** + * Wire protocol version carried in the first header byte. + * + * A decoder MUST reject any other value: the version byte is what lets the + * header layout, RNG, or degree distribution change later without silently + * mis-decoding old parts. + */ +export const AIR_GAP_WIRE_VERSION = 1 + +/** Bytes in the session identifier that names one encoder's stream. */ +export const SESSION_ID_BYTES = 8 + +/** + * Fixed header size in bytes: + * `ver` u8 ‖ `sessionId` 8 bytes ‖ `seq` u32 ‖ `K` u16 ‖ `msgLen` u32 ‖ `crc32` u32. + */ +export const HEADER_BYTES = 23 + /** * Default source-block size in bytes. * - * A 1,200-byte block yields a 1,214-byte part, which is 1,619 unpadded - * base64url characters plus the 8-character prefix — comfortably inside the - * alphanumeric capacity of a version-40 QR symbol at low error correction, - * with margin for a scanner that is not looking at the screen straight on. + * base64url text contains lowercase letters, `-` and `_`, so QR encoders store + * it in **byte mode** (one symbol byte per character), not alphanumeric mode. + * A 1,200-byte block yields a 1,223-byte part body, which is 1,631 unpadded + * base64url characters plus the 8-character prefix: 1,639 bytes in a QR + * symbol. That fits a version-40 symbol in byte mode at every error-correction + * level up to Q (1,663 bytes) and leaves 44% headroom at level L (2,953), for + * a scanner that is not looking at the screen straight on. */ export const DEFAULT_BLOCK_BYTES = 1200 +/** + * Ceiling on the source-block size, enforced by encoder and decoder alike. + * + * Sized to the largest single optical symbol in practical use: a 2,048-byte + * block renders as a 2,770-character part, inside the 2,953-byte byte-mode + * capacity of a version-40 QR symbol at error-correction level L. On the + * decoder this is a resource bound: a scanned string longer than + * `estimatePartCharLength(MAX_BLOCK_BYTES)` is rejected before any base64 + * decoding or allocation happens. + */ +export const MAX_BLOCK_BYTES = 2048 + /** * Sanity ceiling on a whole message. * @@ -31,9 +69,6 @@ export const DEFAULT_BLOCK_BYTES = 1200 */ export const MAX_MESSAGE_BYTES = 65536 -/** Fixed header size in bytes: `seq` u32 ‖ `K` u16 ‖ `msgLen` u32 ‖ `crc32` u32. */ -export const HEADER_BYTES = 14 - /** * Largest source-block count the header can express, since `K` is a u16. * @@ -44,3 +79,37 @@ export const MAX_BLOCK_COUNT = 0xffff /** Exclusive upper bound on `seq`, which the header carries as a u32. */ export const MAX_SEQ_EXCLUSIVE = 2 ** 32 + +/** + * Consecutive parts of one foreign session it takes to switch the decoder. + * + * The decoder locks onto the first session it accepts; a single well-formed + * stray frame from another sender is rejected instead of erasing progress. + * Only this many *consecutive* parts of the same new session — the camera + * really is pointed at a different sender now — adopt it. + */ +export const SESSION_SWITCH_PARTS = 3 + +/** + * Decoder resource budgets. Receiver-local policy, not wire format: they bound + * the memory and work one `AirGapDecoder` can be driven to by a hostile or + * broken sender, and are documented (with the reject/evict semantics) in the + * spec. Honest sessions stay far below all three. + */ + +/** Most distinct sequence numbers remembered for duplicate suppression. */ +export const MAX_TRACKED_SEQS = 65536 + +/** Most unsolved multi-block parts buffered for peeling. */ +export const MAX_PENDING_PARTS = 1024 + +/** + * Most unresolved block references across all buffered parts. + * + * Bounds the Set memory a hostile sender can pin with high-degree mixes. + * Practical sessions (K ≤ ~55 at the default block size) keep this in the + * dozens; only a sender whose parts cannot peel — hostile or badly broken — + * approaches either pending budget, and rejecting its mixes loses nothing + * because systematic and degree-1 parts are always accepted. + */ +export const MAX_PENDING_INDICES = 4096 diff --git a/packages/helpers/air-gap/src/decoder.ts b/packages/helpers/air-gap/src/decoder.ts index 282d0306d..41fc7afaa 100644 --- a/packages/helpers/air-gap/src/decoder.ts +++ b/packages/helpers/air-gap/src/decoder.ts @@ -1,7 +1,18 @@ import { fromB64url } from './base64url' import { blocksForPart, xorInto } from './coding' -import { AIR_GAP_PREFIX, HEADER_BYTES, MAX_MESSAGE_BYTES } from './constants' +import { + AIR_GAP_PREFIX, + AIR_GAP_WIRE_VERSION, + HEADER_BYTES, + MAX_BLOCK_BYTES, + MAX_MESSAGE_BYTES, + MAX_PENDING_INDICES, + MAX_PENDING_PARTS, + MAX_TRACKED_SEQS, + SESSION_SWITCH_PARTS +} from './constants' import { crc32 } from './crc32' +import { estimatePartCharLength } from './helpers' /** What one call to {@link AirGapDecoder.accept} learned. */ export interface AirGapProgress { @@ -21,6 +32,24 @@ interface PendingPart { payload: Uint8Array } +/** The five header fields of a structurally valid version-1 part. */ +interface ParsedPart { + key: string + seq: number + total: number + msgLen: number + crc: number + payload: Uint8Array +} + +/** + * The longest scanned string that could possibly be a valid part: the largest + * allowed block behind the largest allowed header rendering. Anything longer + * is rejected before any base64 work — the length test is the resource gate + * for this untrusted parser, so no allocation scales with hostile input. + */ +const MAX_PART_CHARS = estimatePartCharLength(MAX_BLOCK_BYTES) + /** * Reassembles a message from a stream of scanned parts. * @@ -33,6 +62,14 @@ interface PendingPart { * Nor does it ever emit a partial or unverified message: {@link message} * returns the payload only once all blocks are recovered *and* the CRC matches. * + * The decoder locks onto the first session it accepts. A stray well-formed + * frame from a different sender is rejected rather than allowed to erase + * progress; only {@link SESSION_SWITCH_PARTS} consecutive parts of the same + * new session — the camera really has moved to a different sender — switch + * the decoder over. Memory and work are bounded no matter what the camera + * feeds in: see {@link MAX_TRACKED_SEQS}, {@link MAX_PENDING_PARTS} and + * {@link MAX_PENDING_INDICES}. + * * @example * ```ts * const decoder = new AirGapDecoder() @@ -45,7 +82,7 @@ interface PendingPart { * ``` */ export class AirGapDecoder { - /** `${K}:${msgLen}:${crc}` — identity of the message being received. */ + /** `hex(sessionId):K:msgLen:crc` — identity of the session being received. */ private key = '' private total = 0 private msgLen = 0 @@ -54,25 +91,30 @@ export class AirGapDecoder { * Payload length pinned by the first part accepted into the current session * (0 = unpinned). * - * The session key deliberately excludes the block size, so two honest - * encoders configured with different `blockBytes` — or one part whose payload - * was padded or truncated with its header untouched — can still satisfy the - * `ceil(msgLen / len) === K` agreement check while disagreeing with every - * part already accepted. Mixing those produces blocks of two different - * lengths, and assembly would then size its buffer from one and overrun on - * the other. Pinning turns that into an ordinary rejected read. + * The session identity deliberately excludes the block size, so one part + * whose payload was padded or truncated with its header untouched could + * still satisfy the `ceil(msgLen / len) === K` agreement check while + * disagreeing with every part already accepted. Mixing those produces + * blocks of two different lengths, and assembly would then size its buffer + * from one and overrun on the other. Pinning turns that into an ordinary + * rejected read. */ private blockBytes = 0 private seen = new Set() private solved: (Uint8Array | null)[] = [] private solvedCount = 0 private pending: PendingPart[] = [] + /** Unresolved block references across `pending`, kept ≤ MAX_PENDING_INDICES. */ + private pendingIndices = 0 + /** Foreign session key being counted toward a switch ('' = none). */ + private candidateKey = '' + private candidateCount = 0 /** * Forget everything and wait for a fresh message. * - * Not needed for correctness — a part from a different message resets the - * decoder on its own — but useful when the UI abandons a scan. + * Useful when the UI abandons a scan; the decoder also calls it on itself + * when a completed assembly fails its CRC check. */ reset(): void { this.startSession('', 0, 0, 0) @@ -88,94 +130,153 @@ export class AirGapDecoder { this.solved = Array.from({ length: total }, () => null) this.solvedCount = 0 this.pending = [] + this.pendingIndices = 0 + this.candidateKey = '' + this.candidateCount = 0 } /** Current progress, unchanged, for a read that could not be used. */ private rejected(): AirGapProgress { - return { ok: false, done: false, have: this.solvedCount, total: this.total } + return { ok: false, done: this.isDone(), have: this.solvedCount, total: this.total } } /** Current progress after a read that was used (or was a known duplicate). */ private accepted(): AirGapProgress { - return { - ok: true, - done: this.solvedCount === this.total && this.total > 0, - have: this.solvedCount, - total: this.total - } + return { ok: true, done: this.isDone(), have: this.solvedCount, total: this.total } + } + + private isDone(): boolean { + return this.total > 0 && this.solvedCount === this.total } /** - * Feed one scanned string. - * - * A part belonging to a different message — different `(K, msgLen, crc32)` — - * silently replaces the current session, which is also how the decoder - * recovers after {@link message} discards a corrupt assembly: the sender is - * still looping, so it simply refills. + * Parse one scanned string into header fields, or `null` for anything that + * is not a structurally valid version-1 part. Length is checked *before* + * base64 decoding, so hostile input is rejected without allocation. */ - accept(text: string): AirGapProgress { - if (typeof text !== 'string' || !text.startsWith(AIR_GAP_PREFIX)) return this.rejected() + private parse(text: string): ParsedPart | null { + if (typeof text !== 'string' || !text.startsWith(AIR_GAP_PREFIX)) return null + if (text.length > MAX_PART_CHARS) return null let bytes: Uint8Array try { bytes = fromB64url(text.slice(AIR_GAP_PREFIX.length)) } catch { - return this.rejected() + return null } - if (bytes.length <= HEADER_BYTES) return this.rejected() + if (bytes.length <= HEADER_BYTES || bytes.length > HEADER_BYTES + MAX_BLOCK_BYTES) return null const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) - const seq = view.getUint32(0) - const total = view.getUint16(4) - const msgLen = view.getUint32(6) - const crc = view.getUint32(10) + if (view.getUint8(0) !== AIR_GAP_WIRE_VERSION) return null + let session = '' + for (let i = 1; i <= 8; i++) session += bytes[i].toString(16).padStart(2, '0') + const seq = view.getUint32(9) + const total = view.getUint16(13) + const msgLen = view.getUint32(15) + const crc = view.getUint32(19) const payload = bytes.subarray(HEADER_BYTES) - if (total === 0 || msgLen === 0 || msgLen > MAX_MESSAGE_BYTES) return this.rejected() + if (total === 0 || msgLen === 0 || msgLen > MAX_MESSAGE_BYTES) return null // Block size, msgLen and K must agree, or the sender and this decoder are // not talking about the same message shape. - if (Math.ceil(msgLen / payload.length) !== total) return this.rejected() + if (Math.ceil(msgLen / payload.length) !== total) return null + return { key: `${session}:${total}:${msgLen}:${crc}`, seq, total, msgLen, crc, payload } + } - const key = `${total}:${msgLen}:${crc}` - if (key !== this.key) this.startSession(key, total, msgLen, crc) + /** + * Feed one scanned string. + * + * Unusable reads change nothing. Parts of a *different* session are + * rejected while the current one is in progress, until + * {@link SESSION_SWITCH_PARTS} consecutive parts of the same new session + * arrive — then the decoder adopts that session and starts over. Once the + * current message is complete, further parts of its session are + * acknowledged without any state change. + */ + accept(text: string): AirGapProgress { + const part = this.parse(text) + if (part === null) return this.rejected() + + if (this.key === '') { + this.startSession(part.key, part.total, part.msgLen, part.crc) + } else if (part.key !== this.key) { + // Foreign session: never let one stray frame erase progress. Count + // consecutive sightings of the same candidate; a camera genuinely + // pointed at a new sender produces them back to back. + if (part.key === this.candidateKey) this.candidateCount++ + else { + this.candidateKey = part.key + this.candidateCount = 1 + } + if (this.candidateCount < SESSION_SWITCH_PARTS) return this.rejected() + this.startSession(part.key, part.total, part.msgLen, part.crc) + } else { + // A part of the locked session interrupts any foreign-candidate run. + this.candidateKey = '' + this.candidateCount = 0 + } + + // A completed session is immutable: acknowledge and change nothing. + if (this.isDone()) return this.accepted() // The agreement check above admits a *range* of payload lengths for a given // (msgLen, K); only the pin can tell two block sizes apart. - if (this.blockBytes === 0) this.blockBytes = payload.length - else if (payload.length !== this.blockBytes) return this.rejected() + if (this.blockBytes === 0) this.blockBytes = part.payload.length + else if (part.payload.length !== this.blockBytes) return this.rejected() - if (this.seen.has(seq)) return this.accepted() - this.seen.add(seq) + if (this.seen.has(part.seq)) return this.accepted() - const indices = seq < total ? new Set([seq]) : new Set(blocksForPart(seq, total)) - this.ingest({ indices, payload }) + const indices = + part.seq < this.total ? new Set([part.seq]) : new Set(blocksForPart(part.seq, this.total)) + const candidate: PendingPart = { indices, payload: part.payload } + this.reduce(candidate) + if (candidate.indices.size > 1) { + // Buffering is the one place hostile input could grow state without + // bound, so mixes are budgeted; solved blocks and duplicates are not + // affected, and systematic parts always land, which preserves liveness. + if ( + this.pending.length >= MAX_PENDING_PARTS || + this.pendingIndices + candidate.indices.size > MAX_PENDING_INDICES + ) { + return this.rejected() + } + this.pending.push(candidate) + this.pendingIndices += candidate.indices.size + this.remember(part.seq) + return this.accepted() + } + this.remember(part.seq) + if (candidate.indices.size === 1) { + this.solve(candidate) + this.cascade() + } return this.accepted() } + /** Duplicate suppression, capped: past the cap, repeats are re-processed. */ + private remember(seq: number): void { + if (this.seen.size < MAX_TRACKED_SEQS) this.seen.add(seq) + } + /** - * Peeling: reduce a part by what is already known, take it as a solution once - * one unknown block remains, then cascade — one solve can unlock a chain of - * previously over-determined parts. + * Peeling: after a new solve, reduce every buffered part by what is now + * known; each pass may solve more parts, which unlocks the next pass. */ - private ingest(part: PendingPart): void { - this.reduce(part) - if (part.indices.size === 0) return // pure redundancy - if (part.indices.size > 1) { - this.pending.push(part) - return - } - this.solve(part) - // Reducing each pending part inside the loop means an earlier solve in the - // same pass is already accounted for by the time a later part is examined. + private cascade(): void { let progressed = true while (progressed) { progressed = false const still: PendingPart[] = [] + let stillIndices = 0 for (const p of this.pending) { this.reduce(p) if (p.indices.size === 1) { this.solve(p) progressed = true - } else if (p.indices.size > 1) still.push(p) + } else if (p.indices.size > 1) { + still.push(p) + stillIndices += p.indices.size + } } this.pending = still + this.pendingIndices = stillIndices } } diff --git a/packages/helpers/air-gap/src/encoder.ts b/packages/helpers/air-gap/src/encoder.ts index 943d2ed9e..0dcb86bd4 100644 --- a/packages/helpers/air-gap/src/encoder.ts +++ b/packages/helpers/air-gap/src/encoder.ts @@ -2,34 +2,64 @@ import { toB64url } from './base64url' import { blocksForPart, xorInto } from './coding' import { AIR_GAP_PREFIX, + AIR_GAP_WIRE_VERSION, DEFAULT_BLOCK_BYTES, HEADER_BYTES, + MAX_BLOCK_BYTES, MAX_BLOCK_COUNT, MAX_MESSAGE_BYTES, - MAX_SEQ_EXCLUSIVE + MAX_SEQ_EXCLUSIVE, + SESSION_ID_BYTES } from './constants' import { crc32 } from './crc32' import { AirGapError } from './errors' +/** Construction options for {@link AirGapEncoder}. */ +export interface AirGapEncoderOptions { + /** + * Payload bytes per part, `1..MAX_BLOCK_BYTES`. Not carried on the wire; + * pick it from the byte-mode capacity of the symbol the receiving camera + * can actually resolve. Defaults to {@link DEFAULT_BLOCK_BYTES}. + */ + blockBytes?: number + /** + * Exactly {@link SESSION_ID_BYTES} bytes naming this encoder's stream on + * the wire. Defaults to fresh random bytes, so two encoders — even of the + * same message — are distinct sessions to a decoder. Pass an explicit value + * to make part strings fully deterministic (conformance vectors do), or to + * resume the same session across encoder instances. + */ + sessionId?: Uint8Array +} + /** - * Turns one message into an endless, deterministic sequence of wire parts. + * Turns one message into a deterministic sequence of wire parts. * * An encoder is immutable and holds no cursor: `partAt(seq)` is a pure function - * of `(message, blockBytes, seq)`. The caller owns the sequence number and the - * cadence, which is what lets a static single-part display, a 5 fps animation - * and a frozen conformance vector all be the same code path. + * of `(message, blockBytes, sessionId, seq)`. The caller owns the sequence + * number and the cadence, which is what lets a static single-part display, a + * 5 fps animation and a frozen conformance vector all be the same code path. + * + * Senders SHOULD keep looping `seq` (wrapping back to 0 well before the u32 + * ceiling) until the receiver signals success out of band: recovery is + * probabilistic, and the repeating systematic prefix is what guarantees every + * receiver eventually finishes. * * @example * ```ts * const encoder = new AirGapEncoder(payload) * let seq = 0 - * setInterval(() => render(encoder.partAt(seq++)), 200) // the app owns timing + * setInterval(() => { + * render(encoder.partAt(seq)) + * seq = (seq + 1) % (encoder.blockCount * 64) // the app owns timing & wrap + * }, 200) * ``` */ export class AirGapEncoder { /** Views into one zero-padded backing buffer, `blockBytes` each. */ private readonly blocks: readonly Uint8Array[] private readonly crc: number + private readonly session: Uint8Array /** Source block count, `ceil(messageLength / blockBytes)`. Sent as `K`. */ readonly blockCount: number @@ -41,21 +71,25 @@ export class AirGapEncoder { /** * @param message - The bytes to transmit. Copied, so later mutation by the * caller cannot break the determinism contract. - * @param blockBytes - Payload bytes per part. Not carried on the wire; pick - * it from the symbol capacity the receiving camera can actually resolve. + * @param options - Block size and session identity; see + * {@link AirGapEncoderOptions}. * @throws {AirGapError} when `message` is empty or larger than - * {@link MAX_MESSAGE_BYTES}, or when `blockBytes` is not a positive integer - * or would need more than 65,535 source blocks. + * {@link MAX_MESSAGE_BYTES}; when `blockBytes` is not an integer in + * `1..MAX_BLOCK_BYTES` or would need more than 65,535 source blocks; or + * when `sessionId` is not exactly {@link SESSION_ID_BYTES} bytes. */ - constructor(message: Uint8Array, blockBytes: number = DEFAULT_BLOCK_BYTES) { + constructor(message: Uint8Array, options: AirGapEncoderOptions = {}) { + const blockBytes = options.blockBytes ?? DEFAULT_BLOCK_BYTES if (message.length === 0) throw new AirGapError('cannot encode an empty message') if (message.length > MAX_MESSAGE_BYTES) { throw new AirGapError( `message of ${message.length} bytes exceeds the ${MAX_MESSAGE_BYTES}-byte maximum` ) } - if (!Number.isInteger(blockBytes) || blockBytes < 1) { - throw new AirGapError(`blockBytes must be a positive integer, received ${blockBytes}`) + if (!Number.isInteger(blockBytes) || blockBytes < 1 || blockBytes > MAX_BLOCK_BYTES) { + throw new AirGapError( + `blockBytes must be an integer between 1 and ${MAX_BLOCK_BYTES}, received ${blockBytes}` + ) } const blockCount = Math.ceil(message.length / blockBytes) if (blockCount > MAX_BLOCK_COUNT) { @@ -63,6 +97,15 @@ export class AirGapEncoder { `blockBytes of ${blockBytes} needs ${blockCount} blocks, over the ${MAX_BLOCK_COUNT} the header can carry` ) } + if (options.sessionId !== undefined && options.sessionId.length !== SESSION_ID_BYTES) { + throw new AirGapError( + `sessionId must be exactly ${SESSION_ID_BYTES} bytes, received ${options.sessionId.length}` + ) + } + this.session = + options.sessionId === undefined + ? globalThis.crypto.getRandomValues(new Uint8Array(SESSION_ID_BYTES)) + : options.sessionId.slice() this.messageLength = message.length this.blockBytes = blockBytes this.blockCount = blockCount @@ -76,16 +119,25 @@ export class AirGapEncoder { ) } + /** A copy of the 8-byte session identity every part of this encoder carries. */ + get sessionId(): Uint8Array { + return this.session.slice() + } + /** * Part `seq`, ready to render. * * `seq < blockCount` returns source block `seq` verbatim — the systematic - * prefix, so an unlucky-free receiver finishes in exactly `blockCount` reads. - * Past that, parts are XOR mixes and are interchangeable: any `blockCount + ε` - * distinct parts reconstruct the message, so `seq` may grow without bound and - * a missed frame costs almost nothing. + * prefix, so a receiver that catches one clean cycle finishes in exactly + * `blockCount` reads. Past that, parts are XOR mixes and are interchangeable + * *with high probability*: distinct mixes can be linearly dependent, so + * `blockCount + ε` distinct parts complete the message almost always but not + * with certainty (see the recovery-overhead table in the spec). A receiver + * simply keeps scanning; a sender that loops back through the systematic + * prefix makes eventual recovery deterministic. * - * @throws {AirGapError} when `seq` is not a u32. + * @throws {AirGapError} when `seq` is not a u32 — the header's `seq` field + * is finite, not unbounded. */ partAt(seq: number): string { if (!Number.isInteger(seq) || seq < 0 || seq >= MAX_SEQ_EXCLUSIVE) { @@ -99,10 +151,12 @@ export class AirGapEncoder { if (seq < k) payload.set(this.blocks[seq]) else for (const index of blocksForPart(seq, k)) xorInto(payload, this.blocks[index]) const view = new DataView(out.buffer) - view.setUint32(0, seq) - view.setUint16(4, k) - view.setUint32(6, this.messageLength) - view.setUint32(10, this.crc) + view.setUint8(0, AIR_GAP_WIRE_VERSION) + out.set(this.session, 1) + view.setUint32(9, seq) + view.setUint16(13, k) + view.setUint32(15, this.messageLength) + view.setUint32(19, this.crc) return AIR_GAP_PREFIX + toB64url(out) } } diff --git a/packages/helpers/air-gap/src/helpers.ts b/packages/helpers/air-gap/src/helpers.ts index 889697ac2..d10929991 100644 --- a/packages/helpers/air-gap/src/helpers.ts +++ b/packages/helpers/air-gap/src/helpers.ts @@ -1,4 +1,4 @@ -import { AIR_GAP_PREFIX, DEFAULT_BLOCK_BYTES, HEADER_BYTES } from './constants' +import { AIR_GAP_PREFIX, DEFAULT_BLOCK_BYTES, HEADER_BYTES, MAX_BLOCK_BYTES } from './constants' import { AirGapError } from './errors' /** @@ -6,7 +6,7 @@ import { AirGapError } from './errors' * * A prefix test and nothing more — cheap enough to run on every barcode a * camera reports, so a scanner can route reads without paying for base64 - * decoding. Say nothing about whether the part is well formed; only + * decoding. Says nothing about whether the part is well formed; only * {@link AirGapDecoder.accept} can answer that. */ export function isAirGapPart(text: string): boolean { @@ -18,14 +18,20 @@ export function isAirGapPart(text: string): boolean { * * All parts are the same length by construction — the header is fixed and the * last source block is zero-padded — so this is a sizing aid for choosing - * `blockBytes` against a QR version's alphanumeric capacity *before* building - * an encoder, not an estimate that needs a safety margin. + * `blockBytes` *before* building an encoder, not an estimate that needs a + * safety margin. Compare the result against your QR library's **byte-mode** + * capacity table (base64url text contains lowercase letters, `-` and `_`, so + * QR encoders cannot use alphanumeric mode): version 40 at error-correction + * level L holds 2,953 bytes, and one part character is one byte. * - * @throws {AirGapError} when `blockBytes` is not a positive integer. + * @throws {AirGapError} when `blockBytes` is not an integer in + * `1..MAX_BLOCK_BYTES`, mirroring the encoder's own bounds. */ export function estimatePartCharLength(blockBytes: number = DEFAULT_BLOCK_BYTES): number { - if (!Number.isInteger(blockBytes) || blockBytes < 1) { - throw new AirGapError(`blockBytes must be a positive integer, received ${blockBytes}`) + if (!Number.isInteger(blockBytes) || blockBytes < 1 || blockBytes > MAX_BLOCK_BYTES) { + throw new AirGapError( + `blockBytes must be an integer between 1 and ${MAX_BLOCK_BYTES}, received ${blockBytes}` + ) } const bytes = HEADER_BYTES + blockBytes const remainder = bytes % 3 diff --git a/packages/helpers/air-gap/src/index.ts b/packages/helpers/air-gap/src/index.ts index 0878fa464..249b23c9f 100644 --- a/packages/helpers/air-gap/src/index.ts +++ b/packages/helpers/air-gap/src/index.ts @@ -1,19 +1,30 @@ /** * `@bsv/air-gap` — one-directional optical air-gap transport. * - * Encodes arbitrary bytes as a deterministic, endless sequence of fountain-coded - * parts for display as QR codes (or any optical channel), and reassembles them - * from a camera feed with no back-channel of any kind. + * Encodes arbitrary bytes as a deterministic sequence of fountain-coded parts + * for display as QR codes (or any optical channel), and reassembles them from + * a camera feed with no back-channel of any kind. * * The transport is payload-agnostic and stops at the byte array: rendering, - * scanning, display cadence and payload semantics all belong to the layer above. + * scanning, display cadence and payload semantics all belong to the layer + * above. Wire protocol version 1 is specified in + * `specs/transport/air-gap-optical.md` (BRC-141), with implementation-neutral + * fixtures under `conformance/vectors/transport/`. * * @packageDocumentation */ -export { AIR_GAP_PREFIX, DEFAULT_BLOCK_BYTES, MAX_MESSAGE_BYTES } from './constants' +export { + AIR_GAP_PREFIX, + AIR_GAP_WIRE_VERSION, + DEFAULT_BLOCK_BYTES, + MAX_BLOCK_BYTES, + MAX_MESSAGE_BYTES, + SESSION_ID_BYTES, + SESSION_SWITCH_PARTS +} from './constants' export { AirGapError } from './errors' export { crc32 } from './crc32' -export { AirGapEncoder } from './encoder' +export { AirGapEncoder, type AirGapEncoderOptions } from './encoder' export { AirGapDecoder, type AirGapProgress } from './decoder' export { estimatePartCharLength, isAirGapPart } from './helpers' diff --git a/packages/helpers/air-gap/tests/airGapCodec.property.test.ts b/packages/helpers/air-gap/tests/airGapCodec.property.test.ts index 860af2ac0..be279e7aa 100644 --- a/packages/helpers/air-gap/tests/airGapCodec.property.test.ts +++ b/packages/helpers/air-gap/tests/airGapCodec.property.test.ts @@ -10,11 +10,11 @@ */ import fc from 'fast-check' -import { AIR_GAP_PREFIX, MAX_MESSAGE_BYTES } from '../src/constants' +import { AIR_GAP_PREFIX, MAX_BLOCK_BYTES, MAX_MESSAGE_BYTES } from '../src/constants' import { AirGapDecoder } from '../src/decoder' import { AirGapEncoder } from '../src/encoder' import { estimatePartCharLength } from '../src/helpers' -import { partBytes, toPart } from './helpers' +import { partBytes, SESSION_A, toPart } from './helpers' const MIN_PROPERTY_RUNS = 300 const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) @@ -40,12 +40,14 @@ const payloadUpTo = (maxLength: number) => const payload = payloadUpTo(2048) /** Block sizes from "absurdly small" up to the default. */ const blockBytes = fc.integer({ min: 1, max: 1200 }) +/** An arbitrary but explicit 8-byte session identity. */ +const sessionId = fc.uint8Array({ minLength: 8, maxLength: 8 }) describe('air-gap wire properties', () => { it('round-trips arbitrary bytes through one systematic cycle', () => { fc.assert( - fc.property(payload, blockBytes, (bytes, block) => { - const enc = new AirGapEncoder(bytes, block) + fc.property(payload, blockBytes, sessionId, (bytes, block, session) => { + const enc = new AirGapEncoder(bytes, { blockBytes: block, sessionId: session }) const dec = new AirGapDecoder() for (let seq = 0; seq < enc.blockCount; seq++) { const progress = dec.accept(enc.partAt(seq)) @@ -65,18 +67,20 @@ describe('air-gap wire properties', () => { fc.integer({ min: 32, max: 600 }), fc.array(fc.boolean(), { minLength: 8, maxLength: 64 }), (bytes, block, mask) => { - const enc = new AirGapEncoder(bytes, block) + const enc = new AirGapEncoder(bytes, { blockBytes: block, sessionId: SESSION_A }) const dec = new AirGapDecoder() // A mask that drops every frame is a camera pointed at the floor. if (!mask.includes(true)) return // Otherwise a repeating keep/drop mask stands in for a camera that - // misses frames; the sender keeps looping, so seq climbs regardless - // and the receiver only ever sees the parts the mask lets through. + // misses frames. The sender loops through its systematic cycle + // (seq wraps over 4K), so recovery is guaranteed eventually even if + // the fountain parts that get through are linearly dependent. + const cycle = 4 * enc.blockCount const budget = 30 * enc.blockCount + 200 - for (let seq = 0, seen = 0; seen < budget; seq++) { - if (!mask[seq % mask.length]) continue + for (let tick = 0, seen = 0; seen < budget; tick++) { + if (!mask[tick % mask.length]) continue seen++ - if (dec.accept(enc.partAt(seq)).done) break + if (dec.accept(enc.partAt(tick % cycle)).done) break } expect(Array.from(dec.message()!)).toEqual(Array.from(bytes)) } @@ -93,7 +97,12 @@ describe('air-gap wire properties', () => { fc.string(), fc.string({ unit: 'binary' }), fc.string().map(s => AIR_GAP_PREFIX + s), - fc.uint8Array({ maxLength: 64 }).map(bytes => toPart(bytes)) + fc.uint8Array({ maxLength: 64 }).map(bytes => toPart(bytes)), + // Far past any valid part length: must be rejected by the length + // gate before any base64 work happens. + fc + .integer({ min: estimatePartCharLength(MAX_BLOCK_BYTES) + 1, max: 20000 }) + .map(n => AIR_GAP_PREFIX + 'A'.repeat(n)) ), text => { const dec = new AirGapDecoder() @@ -115,12 +124,12 @@ describe('air-gap wire properties', () => { fc.nat(), fc.integer({ min: 1, max: 255 }), (bytes, block, position, delta) => { - const enc = new AirGapEncoder(bytes, block) + const enc = new AirGapEncoder(bytes, { blockBytes: block, sessionId: SESSION_A }) const dec = new AirGapDecoder() // Corrupt one payload byte of the last systematic part, leaving its - // header — and therefore the session key and the crc — untouched. + // header — and therefore the session identity and the crc — untouched. const raw = partBytes(enc.partAt(enc.blockCount - 1)) - const index = 14 + (position % (raw.length - 14)) + const index = 23 + (position % (raw.length - 23)) raw[index] = (raw[index] + delta) & 0xff for (let seq = 0; seq < enc.blockCount - 1; seq++) dec.accept(enc.partAt(seq)) dec.accept(toPart(raw)) @@ -135,8 +144,8 @@ describe('air-gap wire properties', () => { it('renders every part at exactly the predicted length', () => { fc.assert( - fc.property(payload, blockBytes, fc.nat(), (bytes, block, seq) => { - const enc = new AirGapEncoder(bytes, block) + fc.property(payload, blockBytes, sessionId, fc.nat(), (bytes, block, session, seq) => { + const enc = new AirGapEncoder(bytes, { blockBytes: block, sessionId: session }) const part = enc.partAt(seq) expect(part.startsWith(AIR_GAP_PREFIX)).toBe(true) expect(part).toHaveLength(estimatePartCharLength(block)) @@ -147,7 +156,7 @@ describe('air-gap wire properties', () => { it('reports a block count and message length that agree with its input', () => { fc.assert( fc.property(payload, blockBytes, (bytes, block) => { - const enc = new AirGapEncoder(bytes, block) + const enc = new AirGapEncoder(bytes, { blockBytes: block, sessionId: SESSION_A }) expect(enc.messageLength).toBe(bytes.length) expect(enc.blockCount).toBe(Math.ceil(bytes.length / block)) expect(enc.blockCount * enc.blockBytes).toBeGreaterThanOrEqual(bytes.length) @@ -155,4 +164,32 @@ describe('air-gap wire properties', () => { }) ) }) + + it('never lets a single foreign frame erase progress', () => { + fc.assert( + fc.property( + payloadUpTo(256), + payloadUpTo(256), + fc.integer({ min: 8, max: 64 }), + (bytesA, bytesB, block) => { + const encA = new AirGapEncoder(bytesA, { blockBytes: block, sessionId: SESSION_A }) + const encB = new AirGapEncoder(bytesB, { + blockBytes: block, + sessionId: Uint8Array.from([255, 254, 253, 252, 251, 250, 249, 248]) + }) + const dec = new AirGapDecoder() + const first = dec.accept(encA.partAt(0)) + // One stray frame from a different session must not reset anything. + const stray = dec.accept(encB.partAt(0)) + expect(stray.have).toBe(first.have) + expect(stray.total).toBe(first.total) + if (encA.blockCount === 1) return + expect(stray.ok).toBe(false) + // The locked session keeps decoding to exactly its own bytes. + for (let seq = 1; seq < encA.blockCount; seq++) dec.accept(encA.partAt(seq)) + expect(Array.from(dec.message()!)).toEqual(Array.from(bytesA)) + } + ) + ) + }) }) diff --git a/packages/helpers/air-gap/tests/api.test.ts b/packages/helpers/air-gap/tests/api.test.ts index 4844d449e..ce326666c 100644 --- a/packages/helpers/air-gap/tests/api.test.ts +++ b/packages/helpers/air-gap/tests/api.test.ts @@ -3,21 +3,33 @@ * these names; this asserts the source does, so the two cannot drift apart. */ import * as airGap from '../src/index' -import { AIR_GAP_PREFIX, DEFAULT_BLOCK_BYTES, MAX_MESSAGE_BYTES } from '../src/constants' -import { AirGapEncoder } from '../src/encoder' +import { + AIR_GAP_PREFIX, + AIR_GAP_WIRE_VERSION, + DEFAULT_BLOCK_BYTES, + MAX_BLOCK_BYTES, + MAX_MESSAGE_BYTES, + SESSION_ID_BYTES, + SESSION_SWITCH_PARTS +} from '../src/constants' import { AirGapError } from '../src/errors' import { estimatePartCharLength, isAirGapPart } from '../src/helpers' -import { message } from './helpers' +import { message, SESSION_A } from './helpers' +import { AirGapEncoder } from '../src/encoder' describe('public surface', () => { it('exports exactly the documented names', () => { expect(Object.keys(airGap).sort()).toEqual([ 'AIR_GAP_PREFIX', + 'AIR_GAP_WIRE_VERSION', 'AirGapDecoder', 'AirGapEncoder', 'AirGapError', 'DEFAULT_BLOCK_BYTES', + 'MAX_BLOCK_BYTES', 'MAX_MESSAGE_BYTES', + 'SESSION_ID_BYTES', + 'SESSION_SWITCH_PARTS', 'crc32', 'estimatePartCharLength', 'isAirGapPart' @@ -26,8 +38,12 @@ describe('public surface', () => { it('pins the wire constants', () => { expect(AIR_GAP_PREFIX).toBe('air-gap:') + expect(AIR_GAP_WIRE_VERSION).toBe(1) + expect(SESSION_ID_BYTES).toBe(8) expect(DEFAULT_BLOCK_BYTES).toBe(1200) + expect(MAX_BLOCK_BYTES).toBe(2048) expect(MAX_MESSAGE_BYTES).toBe(65536) + expect(SESSION_SWITCH_PARTS).toBe(3) }) }) @@ -59,15 +75,15 @@ describe('isAirGapPart', () => { }) it('accepts every part a real encoder produces', () => { - const enc = new AirGapEncoder(message(3700), 1200) + const enc = new AirGapEncoder(message(3700), { blockBytes: 1200, sessionId: SESSION_A }) for (const seq of [0, 1, 2, 3, 4, 99]) expect(isAirGapPart(enc.partAt(seq))).toBe(true) }) }) describe('estimatePartCharLength', () => { it('matches the real part length exactly', () => { - for (const blockBytes of [1, 2, 3, 4, 8, 37, 1200, 1500, 4096]) { - const enc = new AirGapEncoder(message(8192), blockBytes) + for (const blockBytes of [1, 2, 3, 4, 8, 37, 1200, 1500, MAX_BLOCK_BYTES]) { + const enc = new AirGapEncoder(message(8192), { blockBytes, sessionId: SESSION_A }) expect(estimatePartCharLength(blockBytes)).toBe(enc.partAt(0).length) expect(estimatePartCharLength(blockBytes)).toBe(enc.partAt(enc.blockCount + 3).length) } @@ -75,7 +91,15 @@ describe('estimatePartCharLength', () => { it('defaults to the default block size', () => { expect(estimatePartCharLength()).toBe(estimatePartCharLength(DEFAULT_BLOCK_BYTES)) - expect(estimatePartCharLength()).toBe(1627) + expect(estimatePartCharLength()).toBe(1639) + }) + + it('stays inside a version-40 QR symbol in byte mode at the default', () => { + // base64url renders in QR byte mode (one byte per character); version 40 + // at error-correction L holds 2,953 bytes. Both the default and the + // absolute block ceiling must fit, or the documented sizing story is wrong. + expect(estimatePartCharLength(DEFAULT_BLOCK_BYTES)).toBeLessThanOrEqual(1663) // v40-Q + expect(estimatePartCharLength(MAX_BLOCK_BYTES)).toBeLessThanOrEqual(2953) // v40-L }) it('grows monotonically with the block size', () => { @@ -87,12 +111,13 @@ describe('estimatePartCharLength', () => { } }) - it('refuses a block size that is not a positive integer', () => { + it('refuses a block size outside the encoder bounds', () => { expect(() => estimatePartCharLength(0)).toThrow(AirGapError) expect(() => estimatePartCharLength(-8)).toThrow(AirGapError) expect(() => estimatePartCharLength(1.5)).toThrow(AirGapError) + expect(() => estimatePartCharLength(MAX_BLOCK_BYTES + 1)).toThrow(AirGapError) expect(() => estimatePartCharLength(0)).toThrow( - 'blockBytes must be a positive integer, received 0' + `blockBytes must be an integer between 1 and ${MAX_BLOCK_BYTES}, received 0` ) }) }) diff --git a/packages/helpers/air-gap/tests/decoder.test.ts b/packages/helpers/air-gap/tests/decoder.test.ts index 6bbde7090b9e336aedd7946e0d53cca2e3e9bdd3..5c1cc4d9ce8ee99c68fd6d5baa32b4e7e947cadc 100644 GIT binary patch literal 20661 zcmd5^>vG#hmj3UjXjgehOk0O*dF&0>MH!z*r6w9bpGWymIz27t_1DaGve0?B^=MYoq@J4QQJnVFxh{(6 zRQJ>ZPY*6jUG$VC%&7W!&{Jg=a&*Tne`k_`N($Rl&r~PT>Ap&r=VP7sL>hS6>REwT z##wqa-B*8()AGqE&!bDsbg%8IXa5E{NcDvhG8(#c%x38o-JX>-vuU@gy6)b&Gu6c; zkH@;R6)<9}r#iH{y2AQ#+1V0K6e6Wrs?{XXQL2i|qSPR8JW-QNJPPJ1CFeaaPO)pd zr-!?{yIr-jqh8|SLlu>3Fx=h6YwU2m3g1+RGP2R>dSkL>i80KY4#xM0rX5H*Y2@8*7vl~;E~BL5 zeS<@FX;C7gkSN!r*@ar<*=Y{esVJSQ_%zM%hlEveZn80SecbkvJcMS zWW=`T$_uOO&f3J4cSkJ%>2sDmcfDbwalP=3S*;-%XHxtDsWwszx+tD@= zeyc%rL+BQ&LsZQPc3Y@!6^9q}Myd1}QUx)x{iV^R$z;gzs_q^SCJVwDq{5 zqCN@~bz@0{W*?u$3$~Mm8n71ZL9WX<*U9BmHPd>k%DGlhi&AAX{yd9w{83(HVn&L- z_c~wK`LR-@^5!Ec4DHk^zIjaQ55aiQQxALUQBOVYsV6=49Y)bei1`5D4qzGv7&5?^ z0R{~)YJg#b@80n;fdc$iMx((x1_HBenU-)ohKx}HU9Is9ggykHF|f@$ z?mhOrBSbTapci~PNWyfgr(Z#5(i(O!^b$xp=tzzdN=XKgqe0>WDCPul!bsIGgDzsX_u$O;+|ICJ0O9L&?_k3cj#ebsmyh`%+ms)Ns?H^6ah1wk3k)f6K(_wS+*-6UbzOm(}pZO z4FS6cR+%rB4E_lShp)gtvf~KeO&4?QaB-=0G?^XEUAG z#H-Dg)D3fywJk*SdGG}OX-pB~#-^th$b|M;Y%&EhMj2&$0+|p5`6Rk)h5C@JC-?;m zftXje(^HTdXx~MgPO}TB1TX;+<5!($z^vq59AN~287}av`gA0$1s5!g=rBoqZkcgR z(sjdm+{x}c^*_(l?&sOeU&Aw{;mJHa?Tx|tYGIg-xQa4|Q6=q%OCjJ(hi4ev%I)=n z>xrY0-vH|Ovok{z2Kj;O^kjakJo}gr_6IwAJ5wD^$6C*}x|_}&7oQf<5Aer_P50eginjmkYvyk}H#|ezj;~$xE@r=+=h+4D zT&m_7TncbUoGzB-EhI*i)UNVOov@I%rST}pZl_X>(W4K2aY2;n=vs)}oVtU5KCJ%w z-qkUy24^^)1Ax8GkX=HW;Ck((t{b-#+o#cD%f4|7WZy=nk_FX0U8Yk# zi&Hi+YMGv;*#&}c_*dSGoy$H4Pgf3jFr-NBsw2!P^=A%14U@Vy z)ghTLPj6|U^SN(HL`=eHgT?C0w5WR}gt`7J@>2wei%FCcv4lJd;om7x32Yf>EYznc ziKlGMtcS*X1YO0qm)0acM`{E~sMf4$r620U#@My<9Qpn2! zNbW26cckPHc8Ravujlm<(c()a(=ilrueSi@NUO|noj){B&Pf)=ej=*y5_N~wB8BNC zumU_Mj!ubam4U%V2nV+|iI%vM;`O%wM7HSZlzZ=t)46~^!-o&wbvY?bXKH%oiq2Pd z*S!@~MxMvf)N&k3?mW4pb0syFir4&|dAk|saoyiUJBRJNxkA!06zNh@!+x%_i88iC zpNdOGHY>L)gE7vgmx6U5wR4~goo{C;5FqSbF-Ky01_%3!Nm}@JQzX2y9^|QJ%;6gW#;-fr}j23`IYn+t001X_Mt%5|Jq)c4rgY#BT;H ztCA%B0<}cl2?HDhe?(rI_UgmudQ@jt0a&_sMehhOlT+U|nggC~eRlCc`!;T~Xt+|_ z3Qb2?am#~nOfo9+UCd)-ViwT^byq6Df_I2^GSmQT%z}b_h{|mIfiXAom80Y5M@Qa# z)-o~&RJ-5=HLVB0JV6Gq(AG$ynkb1MEGPyGEJlpTP{k7=GD<@PJkY)n;-{du2&ry@FY@I(#5r0pJP65K0vd1ABS1u?IS}262eOP34)t z+}c-^;Sv&;L*jJXH5Ql_4j7>_TXJHP%e@fAN*8iKz?y`M!Lb76bxUk)jyx)O#%Ptn zxus!-PYCPzwi;gmsQQ^w)UL>Xv~_qCEgABPs?!XPop_8iHb;1fvXRK2<&iCrM);Ou zokFvn*E#ZDuCb$)tP#b{K~B4B3K|K8g#KeQuyP00zyp8adcpNr5mkOc=C^%^jtXWo z1fJq`_%w}7shqhA*Aal&?iE-`q>x1kWamO9$vB%V=(zrBV^w(Q=Drv_tg{^Zd`syB zG|9naTZyNfT;eJa#!y;IR+UU5h=!Ak7Ey36&C4Vc(QYi32(IEzBY-v#v=XWUt^vA; zgfrQBQhK{-$}i*)j%;~KwTZOQlVurys+d4lvORM7XOb$z5ve=a9yGrvP;H~#qli!E zsKp`iPC0k6M|#@tiy(1uPg)m43wEF4f8V=yw)#@|_Wu2K!M+o|ctz|faXR*m*ORYE zDdFccfU-N$G+Q9$Cf|EZqvkDc=0X(^4Kslv2&<=LkE)86+s-Q57!9;4Viu{QW(#ec zbQ+PG0@I==%LI)>r^heiG)A?nw)3ZOiPY<}%Pw-DE;RVSnScrPN9d`zH?Rn4K6|dF z3XM$*-M1|^8vWtId`*9-9f&th=dtFAw=B&}^tfgXV4`%MFNu8(I436SpGkk5?C0PE z$qwXSK7Owrn2)}|lfI!L%96q<7HlOocLnrdFBP4lW{mbxE?`@8By#ISs!d+eAXo{Z zhv?Q$flvP5}oHFr%LtT!j zIT)01Cw4Iw z#%O_lJeEzQ_R!+REq|@ShM1DCv5vBW93NQhSbs!wpWt+zV`kbf-GlRgBPB}B*?DbM|R79b<+l}Sf;U`U|A1~LX;51f#WdAjcX0Y%R-D$ zN=L6L$q!4W!FCCPZCrYpYb!_+!xmYBAe<%B2)fe`C(b|%8Tvq1nRa)q8!p-W?uOk{ ze+JB^ha6Q3g1^+*8clLkP%D$rmV^M+M^Miko*H@B9Ap%gM*|@;=`(YZlIj7005KrwMMpna&={QAmypb`qf)znC5t(ZvCu z`VDuu;5(MmHal>}%8d-i8A_%@^{;{Un~RvO`tLayCt#%9tZ}aULAedyzt67+v{bON z+8fc_ElHvot<)IkM!G>Tm!Ae9SZ#J2*n10;U6+PDUMy^yfz&W$exOR0#Ip2H(AT#B zhTO>#m?<$X;OudA;ZV{XVMdVE-r^KoEm)xV!_Nqy8CjPN5HDS>GsxpOlG_-`8B*{7 z{2o+n{nMY+UAqY5VaHDU6ysNNlL>B2r5)dns=p5Soz+v7P(ifgyZeYpeoxNGX41_S z>D4E;S*T9d6FfBiU6@-7LckS93v`v!wM(JSW&7=4(1kBfKn3uDED2^HE@LxXW=syN zyhvaD<8S|`7E4YdPJw06j*n&*m@Z?=b_sv*+$j$=4cT<5ZyT|rU9GYzRJQZaWq>># z9Ds)fHN+k>{42mp7K=0S%6mJhj~YuriZts?mH;*r~uEww>9&zC3ZC zEcuaf4-iuY#Ma2%%HaL$%nUVffr3?f10Wym5`gxU$lyFJh-vPTRHkNXj zh;*Q)3Zb-x8IED?YyBh_gRWti98YDeW>#5z`hOuH^F#!E#AGC@lklV^xvK&4SX{`wiek%3Efe;e%L zb$Nx8;dkM+37Kx``wUp@HRP?sgSok#Z_Bz{H(uI}7j24(S!zXo#~W{X!ub~OUXCLE zkS=l#kE2AnQf_@5eIdCxzK=uASF3bj^VA8WFnf(1F{r60x(n52Po|(tu7gX7NtRK# zARScZM-BLw3|yYa6dBtT(*gvKD5fszFvPdkwZ?Uqs^ZUfCTw6fEp4?93KCl!n%c|R zYNKY?xK2srLh(T?86~OF!Ga6st_%8j^z)(BO6ifKRr#sK4QNn@;#ifaGVlCOmT|OH z0qjF8rc2mk0TBeYNXzY&rA4wtZeU9H$0w1?1b9s#qiO=!#TfA##s<-?$-vGW3f(%G7yb+f`n@q0QYR5A z{lte&eT{4`(k_a)XX8aNc9TU{+e1E%FYB~V&uEZtLw3Z0Sx#3{)bev4_`LzKgSB$0 z9$>TH%KT=dN!6Un)qBZ+=BOrwYL4*>M<}9N=98te2Pu~MCl}<}P1M}FIE66mp!(w~ z6Q+F%{;}rZ0$2tmaO!EHb|s-*T&QTY{IcFz~)ISHCTH`-p%73?R#K2US(-Gg!e+ z3RBp{I@K#$f)=B3MNpx&ie|p_EsN#wZ>|&cQ$_Gy4t~JIGc@URTM?&)l9X>km`69^ zSrCMTt4$}xQf=q6D532;i|yz;vnPI+N3PW{%Nt~1$2WybBLm>DH*)?|?xMaD(i(QB zW@Ca07|G$MqzLh@w1k%9pvbx!T>#}8-GEhmQ6hVV4dJpJDvOBuxsbmN*n#dd9i7|O z{R(*yRDcP@HF4AZX6Dqy050y>uT{nf?Ow7M5ZHlQqH81IHcxQ|es_VPja=5kdd6N@ zQu2=nPy-?=j3#C*fwl<7P+&CX$kJ>QKXL*?5}ldrp*wbivn R8ueTBX&Ex%4A3_G`~OKb;7$Ml delta 1372 zcmZ`&-*4Mg6edm5WJ#N@+S-Pst4_<>*u`?3w$m7*FnhRN8iqh? z^PO|Af8={;JZ;wMcEg2>ATOONF3PLL)l=%i(wYD{d99dJ3k7*mEh=YTRts?X+pBoh zH--0oN!;l{Ke2SYrzY_G@Nv8r4fhIgRuTa7lX1KeT0=Y7i}(G{_IOdl_+vg4JR(UF zkKctQgjg7hfhcp{Ihw);f!W8O&9UGWUl)RiKlPqtC_VwpA-Q=il<>7GGyDR3PVGyx z>-4@yU3nfe+==}1AKT67$9ru-*1SOrZ zViIpFaa&|j1T`B?Z;OV zZ}~HG{ClH3-b@VpGqdzL)RdC90e!w=Ks7hmxX7h?=gGy30{L6udJP zkBP4BYE_>06{PS?JEmA?N5&S&&%rrPQOKaQP2TuN(?^0 + new AirGapEncoder(message(len), { blockBytes, sessionId: SESSION_A }) describe('AirGapEncoder', () => { it('emits parts of constant size with the right prefix', () => { - const enc = new AirGapEncoder(message(5000), 1200) - const first = enc.partAt(0) - const fountain = enc.partAt(enc.blockCount + 1) + const e = enc(5000, 1200) + const first = e.partAt(0) + const fountain = e.partAt(e.blockCount + 1) expect(first.startsWith(AIR_GAP_PREFIX)).toBe(true) expect(fountain.startsWith(AIR_GAP_PREFIX)).toBe(true) expect(first).toHaveLength(fountain.length) @@ -33,44 +39,83 @@ describe('AirGapEncoder', () => { }) it('accepts exactly the maximum message size', () => { - const enc = new AirGapEncoder(message(MAX_MESSAGE_BYTES)) - expect(enc.messageLength).toBe(MAX_MESSAGE_BYTES) - expect(enc.blockCount).toBe(Math.ceil(MAX_MESSAGE_BYTES / DEFAULT_BLOCK_BYTES)) - }) - - it('refuses a block size that is not a positive integer', () => { - expect(() => new AirGapEncoder(message(10), 0)).toThrow(AirGapError) - expect(() => new AirGapEncoder(message(10), -1)).toThrow(AirGapError) - expect(() => new AirGapEncoder(message(10), 1.5)).toThrow(AirGapError) - expect(() => new AirGapEncoder(message(10), Number.NaN)).toThrow(AirGapError) - expect(() => new AirGapEncoder(message(10), 1.5)).toThrow( - 'blockBytes must be a positive integer, received 1.5' + const e = new AirGapEncoder(message(MAX_MESSAGE_BYTES)) + expect(e.messageLength).toBe(MAX_MESSAGE_BYTES) + expect(e.blockCount).toBe(Math.ceil(MAX_MESSAGE_BYTES / DEFAULT_BLOCK_BYTES)) + }) + + it('refuses a block size that is not an integer in range', () => { + expect(() => enc(10, 0)).toThrow(AirGapError) + expect(() => enc(10, -1)).toThrow(AirGapError) + expect(() => enc(10, 1.5)).toThrow(AirGapError) + expect(() => enc(10, Number.NaN)).toThrow(AirGapError) + expect(() => enc(10, 1.5)).toThrow( + `blockBytes must be an integer between 1 and ${MAX_BLOCK_BYTES}, received 1.5` + ) + }) + + it('enforces the wire ceiling on the block size', () => { + // MAX_BLOCK_BYTES is the largest block a single optical symbol can carry; + // the decoder rejects anything bigger, so the encoder must too. + expect(enc(10, MAX_BLOCK_BYTES).blockBytes).toBe(MAX_BLOCK_BYTES) + expect(() => enc(10, MAX_BLOCK_BYTES + 1)).toThrow(AirGapError) + expect(() => enc(10, MAX_BLOCK_BYTES + 1)).toThrow( + `blockBytes must be an integer between 1 and ${MAX_BLOCK_BYTES}, received ${MAX_BLOCK_BYTES + 1}` ) }) it('refuses a block size needing more blocks than the u16 K field can carry', () => { // 65,536 bytes at one byte per block would need 65,536 blocks; K tops out // at 65,535, which one byte fewer hits exactly. - expect(new AirGapEncoder(message(MAX_BLOCK_COUNT), 1).blockCount).toBe(MAX_BLOCK_COUNT) - expect(() => new AirGapEncoder(message(MAX_MESSAGE_BYTES), 1)).toThrow(AirGapError) - expect(() => new AirGapEncoder(message(MAX_MESSAGE_BYTES), 1)).toThrow( + expect(enc(MAX_BLOCK_COUNT, 1).blockCount).toBe(MAX_BLOCK_COUNT) + expect(() => enc(MAX_MESSAGE_BYTES, 1)).toThrow(AirGapError) + expect(() => enc(MAX_MESSAGE_BYTES, 1)).toThrow( `blockBytes of 1 needs ${MAX_MESSAGE_BYTES} blocks, over the ${MAX_BLOCK_COUNT} the header can carry` ) - expect(new AirGapEncoder(message(MAX_MESSAGE_BYTES), 2).blockCount).toBe(32768) + expect(enc(MAX_MESSAGE_BYTES, 2).blockCount).toBe(32768) + }) + + it('refuses a session id that is not exactly SESSION_ID_BYTES long', () => { + for (const length of [0, 7, 9, 16]) { + expect(() => new AirGapEncoder(message(10), { sessionId: new Uint8Array(length) })).toThrow( + `sessionId must be exactly ${SESSION_ID_BYTES} bytes, received ${length}` + ) + } + }) + + it('generates a random session id when none is given', () => { + const a = new AirGapEncoder(message(10)) + const b = new AirGapEncoder(message(10)) + expect(a.sessionId).toHaveLength(SESSION_ID_BYTES) + // Two encoders of the same message are distinct sessions on the wire — + // an 8-byte random collision is a once-per-2^64 event. + expect(toHex(a.sessionId)).not.toBe(toHex(b.sessionId)) + expect(a.partAt(0)).not.toBe(b.partAt(0)) + }) + + it('copies the session id in and out, so callers cannot mutate the stream', () => { + const provided = Uint8Array.from(SESSION_A) + const e = new AirGapEncoder(message(10), { sessionId: provided }) + const before = e.partAt(0) + provided[0] ^= 0xff + expect(e.partAt(0)).toBe(before) + const exposed = e.sessionId + exposed[0] ^= 0xff + expect(toHex(e.sessionId)).toBe(toHex(SESSION_A)) }) it('computes blockCount as ceil(len / blockBytes)', () => { - expect(new AirGapEncoder(message(2400), 1200).blockCount).toBe(2) - expect(new AirGapEncoder(message(2401), 1200).blockCount).toBe(3) - expect(new AirGapEncoder(message(37), 1200).blockCount).toBe(1) - expect(new AirGapEncoder(message(1), 1200).blockCount).toBe(1) + expect(enc(2400, 1200).blockCount).toBe(2) + expect(enc(2401, 1200).blockCount).toBe(3) + expect(enc(37, 1200).blockCount).toBe(1) + expect(enc(1, 1200).blockCount).toBe(1) }) it('exposes its configuration read-only', () => { - const enc = new AirGapEncoder(message(2401), 1200) - expect(enc.blockBytes).toBe(1200) - expect(enc.messageLength).toBe(2401) - expect(enc.blockCount).toBe(3) + const e = enc(2401, 1200) + expect(e.blockBytes).toBe(1200) + expect(e.messageLength).toBe(2401) + expect(e.blockCount).toBe(3) }) it('defaults blockBytes to DEFAULT_BLOCK_BYTES', () => { @@ -79,9 +124,10 @@ describe('AirGapEncoder', () => { it('writes the documented header for a systematic part', () => { const msg = message(2401) - const enc = new AirGapEncoder(msg, 1200) - const header = readHeader(enc.partAt(2)) - expect(header).toEqual({ + const e = enc(2401, 1200) + expect(readHeader(e.partAt(2))).toEqual({ + version: AIR_GAP_WIRE_VERSION, + sessionHex: toHex(SESSION_A), seq: 2, k: 3, msgLen: 2401, @@ -92,68 +138,70 @@ describe('AirGapEncoder', () => { it('repeats the same message-wide header fields on fountain parts', () => { const msg = message(2401) - const enc = new AirGapEncoder(msg, 1200) - const header = readHeader(enc.partAt(9)) + const e = enc(2401, 1200) + const header = readHeader(e.partAt(9)) + expect(header.version).toBe(AIR_GAP_WIRE_VERSION) + expect(header.sessionHex).toBe(toHex(SESSION_A)) expect(header.seq).toBe(9) expect(header.k).toBe(3) expect(header.msgLen).toBe(2401) expect(header.crc).toBe(crc32(msg)) }) - it('is deterministic across instances', () => { - const a = new AirGapEncoder(message(3700), 1200) - const b = new AirGapEncoder(message(3700), 1200) + it('is deterministic across instances sharing a session id', () => { + const a = enc(3700, 1200) + const b = enc(3700, 1200) for (const seq of [0, 1, 3, 4, 17, 4096]) expect(a.partAt(seq)).toBe(b.partAt(seq)) }) it('is deterministic across repeated calls for the same seq', () => { - const enc = new AirGapEncoder(message(3700), 1200) - expect(enc.partAt(11)).toBe(enc.partAt(11)) + const e = enc(3700, 1200) + expect(e.partAt(11)).toBe(e.partAt(11)) }) it('zero-pads the final block rather than shortening the part', () => { - const enc = new AirGapEncoder(message(1), 8) - const header = readHeader(enc.partAt(0)) + const e = enc(1, 8) + const header = readHeader(e.partAt(0)) expect(header.payloadLength).toBe(8) expect(header.msgLen).toBe(1) }) it('copies the message so later caller mutation cannot change the parts', () => { const msg = message(64) - const enc = new AirGapEncoder(msg, 32) - const before = enc.partAt(0) + const e = new AirGapEncoder(msg, { blockBytes: 32, sessionId: SESSION_A }) + const before = e.partAt(0) msg[0] ^= 0xff - expect(enc.partAt(0)).toBe(before) + expect(e.partAt(0)).toBe(before) }) it('refuses a sequence number outside u32', () => { - const enc = new AirGapEncoder(message(10), 8) - expect(() => enc.partAt(-1)).toThrow(AirGapError) - expect(() => enc.partAt(1.5)).toThrow(AirGapError) - expect(() => enc.partAt(2 ** 32)).toThrow(AirGapError) - expect(() => enc.partAt(Number.NaN)).toThrow(AirGapError) - expect(() => enc.partAt(-1)).toThrow( + const e = enc(10, 8) + expect(() => e.partAt(-1)).toThrow(AirGapError) + expect(() => e.partAt(1.5)).toThrow(AirGapError) + expect(() => e.partAt(2 ** 32)).toThrow(AirGapError) + expect(() => e.partAt(Number.NaN)).toThrow(AirGapError) + expect(() => e.partAt(-1)).toThrow( 'part sequence must be a 32-bit unsigned integer, received -1' ) }) it('encodes the largest sequence number the header can carry', () => { - const enc = new AirGapEncoder(message(10), 8) - expect(readHeader(enc.partAt(2 ** 32 - 1)).seq).toBe(2 ** 32 - 1) + const e = enc(10, 8) + expect(readHeader(e.partAt(2 ** 32 - 1)).seq).toBe(2 ** 32 - 1) }) it('produces parts of exactly the estimated character length', () => { - for (const blockBytes of [1, 2, 3, 8, 100, 1200, 1500]) { - const enc = new AirGapEncoder(message(3000), blockBytes) - expect(enc.partAt(0)).toHaveLength(estimatePartCharLength(blockBytes)) + for (const blockBytes of [1, 2, 3, 8, 100, 1200, 1500, MAX_BLOCK_BYTES]) { + const e = enc(3000, blockBytes) + expect(e.partAt(0)).toHaveLength(estimatePartCharLength(blockBytes)) } }) it('mixes several source blocks into parts past the systematic prefix', () => { // Some fountain payload must be a genuine XOR of two or more source blocks, // otherwise the fountain has degenerated into plain chunk cycling. - const enc = new AirGapEncoder(message(4000), 1000) - const payload = (seq: number) => partBytes(enc.partAt(seq)).subarray(14).join(',') + const e = enc(4000, 1000) + const payload = (seq: number) => partBytes(e.partAt(seq)).subarray(23).join(',') const sources = new Set([0, 1, 2, 3].map(payload)) const mixes = Array.from({ length: 32 }, (_, i) => payload(4 + i)) expect(mixes.some(mix => !sources.has(mix))).toBe(true) diff --git a/packages/helpers/air-gap/tests/helpers.ts b/packages/helpers/air-gap/tests/helpers.ts index 63db33343..063372cfd 100644 --- a/packages/helpers/air-gap/tests/helpers.ts +++ b/packages/helpers/air-gap/tests/helpers.ts @@ -1,4 +1,7 @@ -import { AIR_GAP_PREFIX } from '../src/constants' +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' + +import { AIR_GAP_PREFIX, AIR_GAP_WIRE_VERSION, HEADER_BYTES } from '../src/constants' import type { AirGapDecoder } from '../src/decoder' import type { AirGapEncoder } from '../src/encoder' @@ -9,6 +12,11 @@ export function message(len: number): Uint8Array { return m } +/** A fixed session identity so vector-adjacent tests stay deterministic. */ +export const SESSION_A = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]) +/** A second fixed session identity, for two-sender tests. */ +export const SESSION_B = Uint8Array.from([0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18]) + /** Feeds `seqs` into `decoder` and returns the message the moment it completes. */ export function drain( decoder: AirGapDecoder, @@ -43,8 +51,10 @@ export function toPart(bytes: Uint8Array): string { ) } -/** The four header fields of a rendered part, read straight off the wire bytes. */ +/** The header fields of a rendered part, read straight off the wire bytes. */ export function readHeader(raw: string): { + version: number + sessionHex: string seq: number k: number msgLen: number @@ -53,27 +63,40 @@ export function readHeader(raw: string): { } { const bytes = partBytes(raw) const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + let sessionHex = '' + for (let i = 1; i <= 8; i++) sessionHex += bytes[i].toString(16).padStart(2, '0') return { - seq: view.getUint32(0), - k: view.getUint16(4), - msgLen: view.getUint32(6), - crc: view.getUint32(10), - payloadLength: bytes.length - 14 + version: view.getUint8(0), + sessionHex, + seq: view.getUint32(9), + k: view.getUint16(13), + msgLen: view.getUint32(15), + crc: view.getUint32(19), + payloadLength: bytes.length - HEADER_BYTES } } /** Builds a wire part from explicit header fields, for negative tests. */ export function craftPart( - header: { seq: number; k: number; msgLen: number; crc: number }, + header: { + version?: number + session?: Uint8Array + seq: number + k: number + msgLen: number + crc: number + }, payload: Uint8Array ): string { - const bytes = new Uint8Array(14 + payload.length) + const bytes = new Uint8Array(HEADER_BYTES + payload.length) const view = new DataView(bytes.buffer) - view.setUint32(0, header.seq) - view.setUint16(4, header.k) - view.setUint32(6, header.msgLen) - view.setUint32(10, header.crc) - bytes.set(payload, 14) + view.setUint8(0, header.version ?? AIR_GAP_WIRE_VERSION) + bytes.set(header.session ?? SESSION_A, 1) + view.setUint32(9, header.seq) + view.setUint16(13, header.k) + view.setUint32(15, header.msgLen) + view.setUint32(19, header.crc) + bytes.set(payload, HEADER_BYTES) return toPart(bytes) } @@ -97,3 +120,53 @@ export function shuffled(count: number, random: () => number): number[] { } return items } + +/** One entry of the shared conformance fixture file. */ +export interface ConformanceVector { + id: string + description: string + input: Record + expected: Record + tags?: string[] +} + +/** + * The implementation-neutral fixtures this package shares with every port. + * + * The file under the repository-root `conformance/` corpus is the contract; + * these tests and the cross-language conformance runner execute the same + * vectors, so the wire format cannot drift between the two. The corpus is + * located by walking up from this file, because sandboxed runs (mutation + * testing copies the package elsewhere in the repository) change how many + * levels up the repository root sits. + */ +export function loadConformanceVectors(): ConformanceVector[] { + const relative = join('conformance', 'vectors', 'transport', 'air-gap-optical.json') + let directory = __dirname + for (;;) { + const candidate = join(directory, relative) + if (existsSync(candidate)) { + const parsed = JSON.parse(readFileSync(candidate, 'utf8')) as { + vectors: ConformanceVector[] + } + return parsed.vectors + } + const parent = dirname(directory) + if (parent === directory) throw new Error(`conformance corpus not found above ${__dirname}`) + directory = parent + } +} + +/** Lowercase-hex → bytes, for conformance vector payloads. */ +export function fromHex(hex: string): Uint8Array { + const out = new Uint8Array(hex.length / 2) + for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) + return out +} + +/** Bytes → lowercase hex, for conformance vector payloads. */ +export function toHex(bytes: Uint8Array): string { + let hex = '' + for (const byte of bytes) hex += byte.toString(16).padStart(2, '0') + return hex +} diff --git a/packages/helpers/air-gap/tests/roundtrip.test.ts b/packages/helpers/air-gap/tests/roundtrip.test.ts index 5d9c490ee..6c45e024d 100644 --- a/packages/helpers/air-gap/tests/roundtrip.test.ts +++ b/packages/helpers/air-gap/tests/roundtrip.test.ts @@ -1,7 +1,7 @@ import { DEFAULT_BLOCK_BYTES, MAX_MESSAGE_BYTES } from '../src/constants' import { AirGapDecoder } from '../src/decoder' import { AirGapEncoder } from '../src/encoder' -import { drain, lcg, message, shuffled } from './helpers' +import { drain, lcg, message, SESSION_A, shuffled } from './helpers' /** Feeds parts until the message decodes or `limit` parts have been sent. */ function transmit( @@ -10,7 +10,7 @@ function transmit( keep: (seq: number) => boolean, limit: number ): Uint8Array | null { - const enc = new AirGapEncoder(msg, blockBytes) + const enc = new AirGapEncoder(msg, { blockBytes, sessionId: SESSION_A }) const dec = new AirGapDecoder() for (let seq = 0; seq < limit; seq++) { if (!keep(seq)) continue @@ -65,7 +65,7 @@ describe('round trip', () => { it('survives arbitrary frame ordering', () => { const random = lcg(0xc0ffee) const msg = message(9000) // K = 8 at 1200 - const enc = new AirGapEncoder(msg, 1200) + const enc = new AirGapEncoder(msg, { blockBytes: 1200, sessionId: SESSION_A }) for (let trial = 0; trial < 20; trial++) { const dec = new AirGapDecoder() // Two cycles' worth of parts, shuffled: a receiver that starts mid-cycle. @@ -77,10 +77,12 @@ describe('round trip', () => { it('needs only a small overhead over K parts on average', () => { // The whole point of the fountain: a receiver that catches parts at random - // finishes in barely more than K reads. + // finishes in barely more than K reads *on average*. Individual runs can + // stall on linearly dependent parts — recovery is probabilistic, which is + // why receivers keep scanning and senders keep looping. const random = lcg(0xa11ce) const msg = message(24000) // K = 20 at 1200 - const enc = new AirGapEncoder(msg, 1200) + const enc = new AirGapEncoder(msg, { blockBytes: 1200, sessionId: SESSION_A }) let sent = 0 const trials = 40 for (let trial = 0; trial < trials; trial++) { @@ -98,9 +100,29 @@ describe('round trip', () => { expect(sent / trials).toBeLessThan(enc.blockCount * 2) }) + it('stalls on linearly dependent parts, then recovers from later ones', () => { + // Six distinct fountain parts that all resolve to source block 0 for + // K = 3: after all six, progress is still 1/3 — "any K + ε distinct parts" + // is provably NOT an absolute guarantee. The stream itself then completes + // recovery, because the sender keeps emitting and later parts carry the + // missing blocks. Pinned as a regression so no doc or port reintroduces + // the absolute claim. + const msg = message(30) // K = 3 at 10 + const enc = new AirGapEncoder(msg, { blockBytes: 10, sessionId: SESSION_A }) + const dec = new AirGapDecoder() + const dependent = [4, 27, 38, 56, 63, 72] + let progress = dec.accept(enc.partAt(dependent[0])) + for (const seq of dependent.slice(1)) progress = dec.accept(enc.partAt(seq)) + expect(progress).toEqual({ ok: true, done: false, have: 1, total: 3 }) + // The sender keeps looping; the receiver keeps scanning; recovery lands. + const out = drain(dec, enc, [73, 74, 75, 76, 77, 78, 79, 80]) + expect(out).not.toBeNull() + expect(Array.from(out!)).toEqual(Array.from(msg)) + }) + it('recovers after a corrupt part while the sender keeps looping', () => { const msg = message(3700) - const enc = new AirGapEncoder(msg, 1200) + const enc = new AirGapEncoder(msg, { blockBytes: 1200, sessionId: SESSION_A }) const dec = new AirGapDecoder() for (let seq = 0; seq < 60; seq++) { // Every third read comes back mangled, as a marginal scan does. @@ -113,19 +135,21 @@ describe('round trip', () => { it('never blends two senders in view of the same camera', () => { const msgA = message(2400) // K = 2 const msgB = message(3700) // K = 4 - const encA = new AirGapEncoder(msgA, 1200) - const encB = new AirGapEncoder(msgB, 1200) + const encA = new AirGapEncoder(msgA, { blockBytes: 1200, sessionId: SESSION_A }) + const encB = new AirGapEncoder(msgB, { + blockBytes: 1200, + sessionId: Uint8Array.from([9, 8, 7, 6, 5, 4, 3, 2]) + }) const dec = new AirGapDecoder() - // Alternating parts from two different messages starve each other — each - // one resets the session the other was building — but nothing is ever - // emitted from the mixture. - for (let seq = 0; seq < 40; seq++) { - expect(dec.accept(encA.partAt(seq)).done).toBe(false) - expect(dec.accept(encB.partAt(seq)).done).toBe(false) - expect(dec.message()).toBeNull() + // Strictly alternating parts never build a SESSION_SWITCH_PARTS run of + // the foreign sender, so the decoder stays locked to whichever it saw + // first — and completes it despite the interference. + let done = false + for (let seq = 0; seq < 40 && !done; seq++) { + done = dec.accept(encA.partAt(seq % 2)).done + if (!done) expect(dec.accept(encB.partAt(seq)).ok).toBe(false) } - // Point the camera at one of them and it decodes immediately. - expect(drain(dec, encA, [0, 1])).not.toBeNull() + expect(done).toBe(true) expect(Array.from(dec.message()!)).toEqual(Array.from(msgA)) }) }) diff --git a/packages/helpers/air-gap/tests/vectors.test.ts b/packages/helpers/air-gap/tests/vectors.test.ts index 31b99af9e..ad8bd3461 100644 --- a/packages/helpers/air-gap/tests/vectors.test.ts +++ b/packages/helpers/air-gap/tests/vectors.test.ts @@ -1,31 +1,119 @@ /** - * Frozen conformance vectors. + * Conformance vectors — the wire format's contract, executed from the shared + * corpus. * - * These strings ARE the wire format. A change to the header layout, the base64 - * variant, the RNG, the degree distribution or the shuffle will break them, and - * that is the point: any implementation in any language must produce these exact - * strings for the same `(message, blockBytes, seq)`. Never regenerate a vector - * to make a test pass — a mismatch means the change is a protocol change. + * The fixtures under `conformance/vectors/transport/air-gap-optical.json` ARE + * the wire format: any implementation in any language must reproduce them + * exactly, and the cross-language conformance runner executes the same file. + * Never regenerate a vector to make a test pass — a mismatch means the change + * is a protocol change, which requires a new wire version and a spec update + * (BRC-141 / `specs/transport/air-gap-optical.md`). + * + * The mapping pins at the bottom freeze the internal part-to-blocks function + * directly, including the u32-seed boundary cases a floating-point port gets + * wrong. */ import { blocksForPart } from '../src/coding' +import { crc32 } from '../src/crc32' import { AirGapDecoder } from '../src/decoder' import { AirGapEncoder } from '../src/encoder' -import { crc32 } from '../src/crc32' -import { drain, message, readHeader } from './helpers' +import { estimatePartCharLength } from '../src/helpers' +import { fromHex, loadConformanceVectors, toHex, type ConformanceVector } from './helpers' + +const vectors = loadConformanceVectors() +const byOperation = (operation: string): ConformanceVector[] => + vectors.filter(v => v.input.operation === operation) + +describe('shared conformance corpus', () => { + it('is present and non-trivial', () => { + expect(vectors.length).toBeGreaterThanOrEqual(25) + }) + + describe('crc32 vectors', () => { + it.each(byOperation('crc32').map(v => [v.id, v] as const))('%s', (_id, v) => { + const bytes = fromHex(v.input.message_hex as string) + expect(crc32(bytes).toString(16).padStart(8, '0')).toBe(v.expected.crc32_hex) + }) + }) + + describe('part-char-length vectors', () => { + it.each(byOperation('part-char-length').map(v => [v.id, v] as const))('%s', (_id, v) => { + expect(estimatePartCharLength(v.input.block_bytes as number)).toBe(v.expected.chars) + }) + }) + + describe('encode-part vectors', () => { + it.each(byOperation('encode-part').map(v => [v.id, v] as const))('%s', (_id, v) => { + const encoder = new AirGapEncoder(fromHex(v.input.message_hex as string), { + blockBytes: v.input.block_bytes as number, + sessionId: fromHex(v.input.session_id_hex as string) + }) + expect(encoder.partAt(v.input.seq as number)).toBe(v.expected.part) + }) + }) + + describe('decode vectors', () => { + it.each(byOperation('decode').map(v => [v.id, v] as const))('%s', (_id, v) => { + const decoder = new AirGapDecoder() + let done = false + for (const part of v.input.parts as string[]) done = decoder.accept(part).done || done + expect(done).toBe(true) + const out = decoder.message() + expect(out).not.toBeNull() + expect(toHex(out!)).toBe(v.expected.message_hex) + }) + }) + + describe('progress vectors', () => { + it.each(byOperation('progress').map(v => [v.id, v] as const))('%s', (_id, v) => { + const decoder = new AirGapDecoder() + let last = decoder.accept('') + for (const part of v.input.parts as string[]) last = decoder.accept(part) + expect(last.have).toBe(v.expected.have) + expect(last.total).toBe(v.expected.total) + expect(last.done).toBe(v.expected.done) + }) + }) -describe('vector V0 — CRC-32 check value', () => { - it('crc32("123456789") is the IEEE check value', () => { - expect(crc32(new TextEncoder().encode('123456789'))).toBe(0xcbf43926) + describe('accept-one rejection vectors', () => { + it.each(byOperation('accept-one').map(v => [v.id, v] as const))('%s', (_id, v) => { + const decoder = new AirGapDecoder() + expect(decoder.accept(v.input.text as string).ok).toBe(v.expected.ok) + expect(decoder.message()).toBeNull() + }) + }) + + it('covers every operation the corpus defines', () => { + const operations = new Set(vectors.map(v => v.input.operation)) + expect([...operations].sort()).toEqual([ + 'accept-one', + 'crc32', + 'decode', + 'encode-part', + 'part-char-length', + 'progress' + ]) }) }) describe('the frozen part-to-blocks mapping', () => { it('pins the block sets the wire format depends on', () => { // These are the sets a decoder rebuilds from `seq` alone. They are the - // coding half of the vectors above; changing them changes the protocol. - expect(blocksForPart(3, 3)).toEqual([1, 2]) + // coding half of the corpus; changing them changes the protocol. + expect(blocksForPart(3, 3)).toEqual([2, 1]) expect(blocksForPart(4, 3)).toEqual([0]) - expect(blocksForPart(5, 5)).toEqual([2, 1, 3]) + expect(blocksForPart(5, 5)).toEqual([1, 3]) + }) + + it('pins the u32 modular seed across the float-precision boundary', () => { + // 3,393,265 is the first seq where naive JavaScript multiplication + // (seq * 0x9e3779b1) has already lost low bits that Math.imul keeps. A + // port using native u32 arithmetic agrees with these sets; a port using + // doubles does not. + expect(blocksForPart(3393264, 5)).toEqual([2, 4]) + expect(blocksForPart(3393265, 5)).toEqual([2, 0]) + expect(blocksForPart(0x7fffffff, 5)).toEqual([3, 0]) + expect(blocksForPart(0xffffffff, 5)).toEqual([1, 0, 3, 2]) }) it('always draws degree 1 when there is one source block', () => { @@ -35,9 +123,10 @@ describe('the frozen part-to-blocks mapping', () => { it('falls back to a fixed seed rather than a zero RNG state', () => { // seq 0 is the only input whose xorshift seed would be 0, and a zeroed // xorshift32 never leaves 0 — every draw would collapse to the same value. - // Unreachable from the encoder (seq 0 is systematic), pinned here so the - // fallback cannot be dropped from a port. - expect(blocksForPart(0, 4)).toEqual([2, 0]) + // Unreachable from the wire (seq 0 is systematic on both sides), pinned + // here so the fallback cannot be dropped from a port that exposes the + // mapping directly. + expect(blocksForPart(0, 4)).toEqual([2, 0, 1, 3]) }) it('always returns distinct in-range indices, at most one per block', () => { @@ -54,116 +143,25 @@ describe('the frozen part-to-blocks mapping', () => { } } }) -}) - -describe('vector V1 — K = 1, "Hello" at blockBytes 8', () => { - const hello = new TextEncoder().encode('Hello') // 48 65 6c 6c 6f - const PART_0 = 'air-gap:AAAAAAABAAAABffRiYJIZWxsbwAAAA' - const PART_1 = 'air-gap:AAAAAQABAAAABffRiYJIZWxsbwAAAA' - const CRC = 0xf7d18982 - it('has the pinned crc and block count', () => { - const enc = new AirGapEncoder(hello, 8) - expect(crc32(hello)).toBe(CRC) - expect(enc.blockCount).toBe(1) - }) - - it('renders the frozen part strings', () => { - const enc = new AirGapEncoder(hello, 8) - expect(enc.partAt(0)).toBe(PART_0) - // With one source block every part carries that block; only seq differs. - expect(enc.partAt(1)).toBe(PART_1) - }) - - it('carries the documented header fields', () => { - expect(readHeader(PART_0)).toEqual({ - seq: 0, - k: 1, - msgLen: 5, - crc: CRC, - payloadLength: 8 - }) - }) - - it('decodes to exactly the five message bytes from that part alone', () => { - const dec = new AirGapDecoder() - expect(dec.accept(PART_0).done).toBe(true) - expect(Array.from(dec.message()!)).toEqual([0x48, 0x65, 0x6c, 0x6c, 0x6f]) - }) - - it('decodes from a later part just as well', () => { - const dec = new AirGapDecoder() - expect(dec.accept(PART_1).done).toBe(true) - expect(new TextDecoder().decode(dec.message()!)).toBe('Hello') - }) -}) - -describe('vector V2 — K = 3, message(10) at blockBytes 4', () => { - // m[i] = (i * 31 + 7) & 0xff → 7,38,69,100,131,162,193,224,255,30 - const BYTES = [7, 38, 69, 100, 131, 162, 193, 224, 255, 30] - const CRC = 0x72c21f0b - const SYSTEMATIC = [ - 'air-gap:AAAAAAADAAAACnLCHwsHJkVk', - 'air-gap:AAAAAQADAAAACnLCHwuDosHg', - 'air-gap:AAAAAgADAAAACnLCHwv_HgAA' - ] - /** A degree-2 mix. */ - const PART_3 = 'air-gap:AAAAAwADAAAACnLCHwt8vMHg' - /** A degree-1 draw: the same payload as source block 0, under a later seq. */ - const PART_4 = 'air-gap:AAAABAADAAAACnLCHwsHJkVk' - - it('has the pinned message bytes, crc and block count', () => { - const msg = message(10) - expect(Array.from(msg)).toEqual(BYTES) - expect(crc32(msg)).toBe(CRC) - expect(new AirGapEncoder(msg, 4).blockCount).toBe(3) - }) - - it('renders the frozen systematic and fountain part strings', () => { - const enc = new AirGapEncoder(message(10), 4) - expect([enc.partAt(0), enc.partAt(1), enc.partAt(2)]).toEqual(SYSTEMATIC) - expect(enc.partAt(3)).toBe(PART_3) - expect(enc.partAt(4)).toBe(PART_4) - }) - - it('zero-pads the last block and still reports msgLen 10', () => { - expect(readHeader(SYSTEMATIC[2])).toEqual({ - seq: 2, - k: 3, - msgLen: 10, - crc: CRC, - payloadLength: 4 - }) - }) - - it('decodes from the three systematic parts alone', () => { - const dec = new AirGapDecoder() - expect(dec.accept(SYSTEMATIC[0]).done).toBe(false) - expect(dec.accept(SYSTEMATIC[1]).done).toBe(false) - expect(dec.accept(SYSTEMATIC[2]).done).toBe(true) - expect(Array.from(dec.message()!)).toEqual(BYTES) - }) - - it('substitutes a fountain part for a missed systematic one', () => { - const dec = new AirGapDecoder() - dec.accept(SYSTEMATIC[0]) - dec.accept(SYSTEMATIC[2]) - // Block 1 was never sent directly; PART_3 mixes it and peels out. - expect(dec.accept(PART_3).done).toBe(true) - expect(Array.from(dec.message()!)).toEqual(BYTES) - }) - - it('treats a degree-1 fountain part as pure redundancy once its block is known', () => { - const dec = new AirGapDecoder() - dec.accept(SYSTEMATIC[0]) - const s = dec.accept(PART_4) - expect(s).toEqual({ ok: true, done: false, have: 1, total: 3 }) - }) - - it('recovers from the frozen strings in any order', () => { - const enc = new AirGapEncoder(message(10), 4) - const dec = new AirGapDecoder() - expect(drain(dec, enc, [4, 3, 2, 1, 0])).not.toBeNull() - expect(Array.from(dec.message()!)).toEqual(BYTES) + it('samples the ideal soliton distribution, not an approximation of it', () => { + // Frequencies over a fixed window of the deterministic mapping. The old + // two-draw sampler put ~20% of K=5 draws on degree 5 instead of 5%; this + // pins the corrected inverse-CDF sampler within a tolerance no accidental + // distribution passes. + const k = 5 + const samples = 20000 + const counts = new Map() + for (let seq = k; seq < k + samples; seq++) { + const d = blocksForPart(seq, k).length + counts.set(d, (counts.get(d) ?? 0) + 1) + } + const frequency = (d: number): number => (counts.get(d) ?? 0) / samples + expect(frequency(1)).toBeGreaterThan(0.18) + expect(frequency(1)).toBeLessThan(0.22) + expect(frequency(2)).toBeGreaterThan(0.47) + expect(frequency(2)).toBeLessThan(0.53) + expect(frequency(5)).toBeGreaterThan(0.035) + expect(frequency(5)).toBeLessThan(0.065) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab25ea5a4..8ccb5033a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,9 @@ importers: conformance/runner/ts: devDependencies: + '@bsv/air-gap': + specifier: workspace:^ + version: link:../../../packages/helpers/air-gap '@bsv/sdk': specifier: workspace:^ version: link:../../../packages/sdk diff --git a/specs/README.md b/specs/README.md index 1226add36..a3e5486f2 100644 --- a/specs/README.md +++ b/specs/README.md @@ -57,28 +57,32 @@ specs/ wallet/ storage-adapter.yaml — OpenAPI 3.1 for wallet storage adapter HTTP boundary (done) + + transport/ + air-gap-optical.md — Markdown wire spec for the BRC-141 optical air-gap transport (experimental) ``` --- ## Spec inventory -| Spec file | Format | Status | Boundary | -|-----------|--------|--------|----------| -| `sdk/brc-100-wallet.json` | JSON Schema Draft 2020-12 | Done | BRC-100 wallet interface (all methods) | -| `overlay/overlay-http.yaml` | OpenAPI 3.1 | Done | Overlay submit, lookup, discovery, admin | -| `broadcast/arc.yaml` | OpenAPI 3.1 | Done | ARC broadcast submit, status, batch, callback | -| `errors.md` | Markdown taxonomy | Done | All error categories and codes | -| `EXCEPTIONS.md` | Tracked gaps | Done | Unspecced boundaries with reasons | -| `messaging/message-box-http.yaml` | OpenAPI 3.1 | Done | message-box-server REST (all 9 endpoints) | -| `messaging/authsocket-asyncapi.yaml` | AsyncAPI 3.0 | Done | AuthSocket WebSocket protocol (all events) | -| `auth/brc103-mutual-auth.yaml` | AsyncAPI 3.0 | Done | BRC-103 mutual auth handshake (both phases) / BRC-104 HTTP transport | -| `payments/brc29-payment-protocol.yaml` | AsyncAPI 3.0 | Done | BRC-29 P2PKH peer payment (key derivation, BEEF message, internalizeAction remittance) | -| `payments/brc121.yaml` | OpenAPI 3.1 | Done | BRC-121 HTTP 402 payment middleware (all 7 headers, 2-trip exchange, replay guards) | -| `sync/gasp-asyncapi.yaml` | AsyncAPI 3.0 | Done | GASP cross-node sync protocol (initial exchange, graph walk, all message shapes) | -| `storage/uhrp-http.yaml` | OpenAPI 3.1 | Done | UHRP resolution HTTP API (upload, find, list, renew) | -| `merkle/merkle-service-http.yaml` | OpenAPI 3.1 | Done | Merkle Service REST API (POST /watch, GET /health) | -| `wallet/storage-adapter.yaml` | OpenAPI 3.1 | Done | Wallet storage adapter HTTP boundary (all operations, all table schemas) | +| Spec file | Format | Status | Boundary | +| -------------------------------------- | ------------------------- | ------------ | ---------------------------------------------------------------------------------------------- | +| `sdk/brc-100-wallet.json` | JSON Schema Draft 2020-12 | Done | BRC-100 wallet interface (all methods) | +| `overlay/overlay-http.yaml` | OpenAPI 3.1 | Done | Overlay submit, lookup, discovery, admin | +| `broadcast/arc.yaml` | OpenAPI 3.1 | Done | ARC broadcast submit, status, batch, callback | +| `errors.md` | Markdown taxonomy | Done | All error categories and codes | +| `EXCEPTIONS.md` | Tracked gaps | Done | Unspecced boundaries with reasons | +| `messaging/message-box-http.yaml` | OpenAPI 3.1 | Done | message-box-server REST (all 9 endpoints) | +| `messaging/authsocket-asyncapi.yaml` | AsyncAPI 3.0 | Done | AuthSocket WebSocket protocol (all events) | +| `auth/brc103-mutual-auth.yaml` | AsyncAPI 3.0 | Done | BRC-103 mutual auth handshake (both phases) / BRC-104 HTTP transport | +| `payments/brc29-payment-protocol.yaml` | AsyncAPI 3.0 | Done | BRC-29 P2PKH peer payment (key derivation, BEEF message, internalizeAction remittance) | +| `payments/brc121.yaml` | OpenAPI 3.1 | Done | BRC-121 HTTP 402 payment middleware (all 7 headers, 2-trip exchange, replay guards) | +| `sync/gasp-asyncapi.yaml` | AsyncAPI 3.0 | Done | GASP cross-node sync protocol (initial exchange, graph walk, all message shapes) | +| `storage/uhrp-http.yaml` | OpenAPI 3.1 | Done | UHRP resolution HTTP API (upload, find, list, renew) | +| `merkle/merkle-service-http.yaml` | OpenAPI 3.1 | Done | Merkle Service REST API (POST /watch, GET /health) | +| `wallet/storage-adapter.yaml` | OpenAPI 3.1 | Done | Wallet storage adapter HTTP boundary (all operations, all table schemas) | +| `transport/air-gap-optical.md` | Markdown wire spec | Experimental | BRC-141 one-directional optical air-gap transport (wire v1, fountain coding, decoder contract) | --- @@ -134,12 +138,12 @@ pnpm run codegen **Toolchain targets:** -| Output | Tool | -|--------|------| +| Output | Tool | +| ------------------------------- | --------------------------------- | | TypeScript types + client stubs | `openapi-typescript`, `quicktype` | -| Go types + client stubs | `oapi-codegen` | -| Python pydantic models | `datamodel-code-generator` | -| Rust types | `typify`, `progenitor` | +| Go types + client stubs | `oapi-codegen` | +| Python pydantic models | `datamodel-code-generator` | +| Rust types | `typify`, `progenitor` | Generated output lands in: diff --git a/specs/transport/air-gap-optical.md b/specs/transport/air-gap-optical.md new file mode 100644 index 000000000..65ffc42c1 --- /dev/null +++ b/specs/transport/air-gap-optical.md @@ -0,0 +1,242 @@ +# Air-Gap Optical Transport — wire protocol v1 (BRC-141) + +Status: **Experimental** — the wire format is versioned and conformance-fixed, +but no independent second implementation has exercised the shared vectors yet. +Until one has, this protocol must not be described as stable. + +Source: `packages/helpers/air-gap` (`@bsv/air-gap`, reference implementation). +Conformance fixtures: `conformance/vectors/transport/air-gap-optical.json`. +Public registration: [BRC-141](https://github.com/bsv-blockchain/BRCs/blob/master/peer-to-peer/0141.md). + +The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be +interpreted as in RFC 2119. + +## 1. Overview + +A one-directional, payload-agnostic transport that carries an arbitrary byte +string across an optical air gap: a sender renders an endless sequence of +**parts** (text strings, typically shown as QR codes), and a receiver +reassembles the original bytes from whatever subset of parts a camera manages +to scan, with no back-channel of any kind. Parts are fountain-coded (Luby +transform) rather than index-numbered, so the receiver does not need any +specific frame to come around again. + +The transport stops at the byte array. Rendering, camera capture, display +cadence, compression, encryption, authentication and payload semantics all +belong to the layers above or below; the transport imposes nothing about them. + +## 2. Terminology + +- **Message** — the bytes to transmit, `1..65 536` bytes. +- **Block** — one fixed-size slice of the message; the last block is + zero-padded. `blockBytes` is `1..2 048` and is _not_ carried on the wire. +- **K** — the source-block count, `ceil(msgLen / blockBytes)`, `1..65 535`. +- **Part** — one wire string: the prefix plus a base64url body encoding a + header and exactly one block-sized payload. +- **Session** — one encoder's stream, named by an 8-byte `sessionId` chosen at + encoder construction (random unless the caller supplies one). +- **Systematic prefix** — parts `0..K-1`, which carry the source blocks + verbatim. + +## 3. Part grammar + +``` +part = "air-gap:" body +body = base64url( header ‖ payload ) ; RFC 4648 §5, UNPADDED +header = ver u8 ‖ sessionId 8 bytes ‖ seq u32 ‖ K u16 ‖ msgLen u32 ‖ crc32 u32 +``` + +- All multi-byte integers are **big-endian**. The header is exactly 23 bytes. +- `ver` MUST be `0x01`. A decoder MUST reject any other value. +- `seq` is the part sequence number, a u32. `seq < K` marks a systematic part. +- `msgLen` is the whole message length in bytes; `crc32` is the IEEE CRC-32 + (polynomial `0xedb88320` reflected form; check value `0xcbf43926` for ASCII + `123456789`) of the whole message and of nothing else. +- The base64url body is unpadded and MUST contain only `A-Z a-z 0-9 - _`. + Decoders MUST reject padding characters, whitespace anywhere, any other + alphabet, and any body whose length ≡ 1 (mod 4). +- `payload` is exactly `blockBytes` bytes: source block `seq` verbatim when + `seq < K`, otherwise the XOR of the source blocks selected by §5. + +Because base64url contains lowercase letters, `-` and `_`, QR encoders store a +part in **byte mode** (one symbol byte per character). A part for a given +`blockBytes` renders at exactly +`8 + 4·floor((23+blockBytes)/3) + tail` characters (tail = 0, 2 or 3 for +remainder 0, 1, 2), e.g. 1 639 characters at the default `blockBytes` 1200 — +inside a version-40 QR symbol at error-correction level Q (1 663 bytes), with +44 % headroom at level L (2 953). The `blockBytes` ceiling of 2 048 renders as +2 770 characters, chosen to keep every legal part inside version 40-L. + +## 4. Encoding + +1. Reject an empty message, a message over 65 536 bytes, a non-integer + `blockBytes` outside `1..2048`, a configuration needing more than 65 535 + blocks, and a `sessionId` that is not exactly 8 bytes. +2. Zero-pad the message to `K × blockBytes` and slice it into K blocks. +3. `partAt(seq)` MUST be a pure function of + `(message, blockBytes, sessionId, seq)`: block `seq` verbatim for + `seq < K`, otherwise the XOR of the blocks chosen by §5 for `(seq, K)`. +4. `seq` is finite (u32). Senders SHOULD loop — re-emitting the systematic + prefix, e.g. cycling `seq` over a window a few multiples of K wide — until + the receiver signals success out of band. Looping through the systematic + prefix is what makes eventual recovery deterministic rather than merely + probable (§6). + +## 5. Part-to-blocks mapping (normative, frozen) + +For `seq ≥ K` the selected block set is reconstructed by the receiver from +`seq` and `K` alone. Every operation below is exact integer arithmetic; a +conforming implementation reproduces it bit for bit in any language. + +**RNG.** xorshift32 with state `x` (u32): + +``` +x ^= x << 13 ; x &= 0xffffffff +x ^= x >> 17 +x ^= x << 5 ; x &= 0xffffffff +``` + +Each call returns the new `x`. Seed: `x = (seq × 0x9e3779b1) mod 2^32` — +**32-bit modular multiplication** (`Math.imul` in JavaScript; plain u32 +multiplication elsewhere). If the seed is 0 (only `seq = 0`, unreachable on +the wire because `seq < K` is systematic), substitute `0x6d2b79f5`. +A JavaScript port MUST NOT use `(seq * 0x9e3779b1) >>> 0`: doubles lose low +bits from `seq = 3 393 265` onward (e.g. at `seq = 0x7fffffff` the float seed +is 3 788 015 616 where the correct u32 product is 3 788 015 183). The +conformance corpus pins parts on both sides of that boundary and at +`0xffffffff`. + +**Draws.** Each draw takes the next RNG output `x` and uses its top 23 bits: +`r = x >> 9`, so `r ∈ [0, 2^23)`. + +**Degree.** One draw `r`; then + +``` +d = floor((2^23 + r) / (r + 1)) ; = ceil(2^23 / (r+1)) +if d > K: d = 1 +``` + +This is an exact inverse-CDF sample of the **ideal soliton distribution** over +`1..K` — `ρ(1) = 1/K`, `ρ(d) = 1/(d(d−1))` for `d ≥ 2` — because the +truncated tail `d > K` has total probability ≈ `1/K`, exactly the mass +`ρ(1)` requires. (For `K = 1` every part is block 0.) + +**Indices.** A partial Fisher–Yates shuffle of the pool `[0, 1, …, K−1]`: +for `i = 0 .. d−1`, draw `rᵢ`, let `j = i + floor(rᵢ · (K − i) / 2^23)`, swap +`pool[i]` and `pool[j]`. The selected set is `pool[0..d−1]`, **in that order** +(order is irrelevant to XOR but is pinned by the vectors). All products stay +below 2^40, so 64-bit integer or double arithmetic is exact. + +## 6. Recovery characteristics (informative, binding on documentation) + +Distinct parts are **not** guaranteed to be linearly independent: recovery +from any `K + ε` distinct parts is probabilistic, not absolute, and +documentation of this protocol MUST NOT claim otherwise. Deterministic +example: for `K = 3`, parts `4, 27, 38, 56, 63, 72` all reduce to source +block 0, so six distinct parts leave progress at 1/3 (pinned in the +conformance corpus). Receivers simply keep scanning; senders keep looping. + +Measured with the reference implementation (400 deterministic trials per +cell): a repair-only receiver that has missed the entire systematic prefix +completes at ~1.4–1.5 K parts at the median and ~3.8–4.6 K at the 99th +percentile (K = 5..55); a receiver watching a sender that loops `seq` over an +8 K-wide cycle completes within 1.5 K reads at the median and ~2.5 K at the +99th percentile, bounded by the next systematic pass. + +## 7. Decoding + +A decoder consumes whatever a barcode library reports and MUST be total: no +input may throw, and no partial or unverified bytes may ever be exposed. + +**Structural acceptance.** Reject (as a no-op) any read that: lacks the +prefix; is longer than the longest legal part (2 770 characters, §3) — this +check MUST precede base64 decoding so hostile input costs no allocation; is +not valid unpadded base64url; decodes to fewer than 24 or more than 2 071 +bytes; has `ver ≠ 1`, `K = 0`, `msgLen = 0`, `msgLen > 65 536`; or fails the +shape agreement `ceil(msgLen / payloadLength) = K`. + +**Session identity and locking.** A session is +`(sessionId, K, msgLen, crc32)`. The decoder locks onto the first session it +accepts. A part of a different session MUST NOT disturb the locked session's +progress; only `SESSION_SWITCH_PARTS = 3` _consecutive_ parts of the same +foreign session switch the decoder to it (starting it fresh). A part of the +locked session, or of a different foreign session, resets the run. Unusable +reads do not affect the count. This is what stops a single stray frame — one +photo of somebody else's screen — from erasing progress, while a camera +genuinely re-pointed at a new sender converges within three frames. + +**Block-size pin.** The session identity excludes `blockBytes`, so the first +accepted part pins the payload length; later parts of the same session with a +different payload length are rejected. This is what stops one padded or +truncated frame (header intact) from being assembled with honest parts. + +**Peeling.** Reduce each accepted part by already-solved blocks; a part left +with one unknown solves that block; each solve re-reduces buffered parts +until no more progress. Duplicate `seq` values are acknowledged without +reprocessing. + +**Completion.** Once all K blocks are solved, concatenate, trim to `msgLen`, +and verify `crc32`. On match, expose the bytes; the session is complete and +further parts of it MUST NOT change any state. On mismatch, discard the +entire assembly and reset — the still-looping sender refills from scratch. +The CRC gate means callers never observe corrupt output; corruption costs one +extra sender cycle. + +**Resource bounds.** Untrusted-input state MUST be bounded. The reference +bounds (RECOMMENDED values; implementations MAY tune them but MUST bound): + +- `MAX_TRACKED_SEQS = 65 536` — duplicate-suppression entries; past the cap, + new sequence numbers are re-processed instead of remembered (idempotent, so + correctness is unaffected). +- `MAX_PENDING_PARTS = 1 024` — buffered unsolved mixes; a mix arriving with + the buffer full is rejected. +- `MAX_PENDING_INDICES = 4 096` — total unresolved block references across + the buffer; a mix that would exceed it is rejected. + +Systematic and degree-1 parts are never buffered, so both rejections preserve +liveness: a looping sender always completes the session through its +systematic prefix. Per-part work is O(K) (index pool) plus O(degree) XOR of +one block; the peeling cascade is bounded by the indices budget. + +## 8. Security considerations + +- **CRC-32 is integrity, not authenticity.** It catches camera misreads and + interleaving accidents. An adversary who can show codes to the camera can + forge any header and any CRC; payloads that matter MUST carry their own + authentication (signature/MAC) inside the message bytes. +- **The session id is an accident guard, not a security boundary.** 8 random + bytes make honest cross-talk vanishingly unlikely (birthday bound 2^-32 at + 65 000 simultaneous sessions), but an active optical attacker sees the + sender's screen and can copy the id. Session locking bounds what such an + attacker can do to _availability_ (they already control the channel); it + cannot provide authenticity. +- **Resource exhaustion.** The §7 bounds cap decoder memory at a few MB and + per-frame work at O(K) against a hostile sender; the pre-decode length gate + caps allocation for non-part garbage at zero. +- **Confidentiality.** Anyone who can see the screen has the message. Encrypt + inside the payload when that matters. + +## 9. Conformance + +`conformance/vectors/transport/air-gap-optical.json` is the normative fixture +set: encoding vectors (including the seed-precision boundary at +`seq = 3 393 265`, `0x7fffffff` and `0xffffffff`), decode and session-locking +vectors, the linear-dependence stall regression, hostile-input rejections, +part-length and CRC check values. The reference implementation's test suite +and the cross-language conformance runner execute the same file. Vectors are +append-only once merged; a change that breaks one is a protocol change and +requires a new `ver` value. + +## 10. Relationship to adjacent transports + +Not wire-compatible with any of: **BRC-225 TKQR1** (indexed pipe-delimited +frames), **BC-UR** (bytewords/CBOR, different fountain), the legacy +**`bsvpayf2:`** browser fountain (its coding contained a JavaScript-specific +seed-precision bug and a mis-sampled degree distribution that this protocol +deliberately does not reproduce), **PiWalletSV `PW1`** (indexed gzip+CBOR +envelopes), or **Vault Manager `CHUNK`**. Those systems own their migration +paths; the convergence plan — this transport as the shared optical layer, a +separate common envelope owning wallet-state semantics, and explicit adapters +for `PW1`/`CHUNK`/`bsvpayf2` — is tracked in the coordination issue linked +from the package documentation. This transport stays payload-agnostic either +way. From bbfcdaea7b49ceac5d7985afd81fad427766c7d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 21:37:01 +0000 Subject: [PATCH 5/7] fix(air-gap): clear Sonar complexity finding and de-flake the loss property Sonar flagged AirGapDecoder.accept at cognitive complexity 18 (limit 15) after the session-locking and budget logic landed; the session routing and peeling ingestion are now extracted into enterSession and ingest with identical behavior, keeping accept at a straight-line read. Coverage stays 100% statements/branches/lines on src. The frame-loss property let the sender's 4K-part loop resonate with the repeating keep/drop mask - CI's seed found a mask keeping exactly one tick in eight against an 8-part cycle, so the receiver saw one seq forever. The loop length is now the smallest prime above both 4K and the mask period, which by CRT guarantees every part is kept within mask.length * cycle ticks and makes the property deterministically terminating. The failing CI seed replays green, plus a 2,000-run soak. Jest now ignores .stryker-tmp so local runs cannot pick up mutation sandboxes. Local Stryker on air-gap-codec: 87.50% against the 85 floor (269 killed, 32 timeouts, 43 survivors of 344 mutants). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013VPatJS7hMnrmHJG9iMRQQ --- packages/helpers/air-gap/jest.config.cjs | 1 + packages/helpers/air-gap/src/decoder.ts | 64 +++++++++++-------- .../tests/airGapCodec.property.test.ts | 29 +++++++-- 3 files changed, 61 insertions(+), 33 deletions(-) diff --git a/packages/helpers/air-gap/jest.config.cjs b/packages/helpers/air-gap/jest.config.cjs index 8cdf5ab85..fc595621e 100644 --- a/packages/helpers/air-gap/jest.config.cjs +++ b/packages/helpers/air-gap/jest.config.cjs @@ -3,6 +3,7 @@ module.exports = { bail: 1, moduleFileExtensions: ['ts', 'js'], modulePathIgnorePatterns: ['out/src', 'out/test', 'dist'], + testPathIgnorePatterns: ['/node_modules/', '/.stryker-tmp/'], rootDir: '.', roots: [''], testEnvironment: 'node', diff --git a/packages/helpers/air-gap/src/decoder.ts b/packages/helpers/air-gap/src/decoder.ts index 41fc7afaa..244cb5c7f 100644 --- a/packages/helpers/air-gap/src/decoder.ts +++ b/packages/helpers/air-gap/src/decoder.ts @@ -193,36 +193,51 @@ export class AirGapDecoder { accept(text: string): AirGapProgress { const part = this.parse(text) if (part === null) return this.rejected() - - if (this.key === '') { - this.startSession(part.key, part.total, part.msgLen, part.crc) - } else if (part.key !== this.key) { - // Foreign session: never let one stray frame erase progress. Count - // consecutive sightings of the same candidate; a camera genuinely - // pointed at a new sender produces them back to back. - if (part.key === this.candidateKey) this.candidateCount++ - else { - this.candidateKey = part.key - this.candidateCount = 1 - } - if (this.candidateCount < SESSION_SWITCH_PARTS) return this.rejected() - this.startSession(part.key, part.total, part.msgLen, part.crc) - } else { - // A part of the locked session interrupts any foreign-candidate run. - this.candidateKey = '' - this.candidateCount = 0 - } + if (!this.enterSession(part)) return this.rejected() // A completed session is immutable: acknowledge and change nothing. if (this.isDone()) return this.accepted() - // The agreement check above admits a *range* of payload lengths for a given - // (msgLen, K); only the pin can tell two block sizes apart. + // The agreement check in parse admits a *range* of payload lengths for a + // given (msgLen, K); only the pin can tell two block sizes apart. if (this.blockBytes === 0) this.blockBytes = part.payload.length else if (part.payload.length !== this.blockBytes) return this.rejected() if (this.seen.has(part.seq)) return this.accepted() + return this.ingest(part) + } + /** + * Route `part` into the right session, locking and switching as documented + * on {@link accept}. Returns `false` when the part belongs to a foreign + * session that has not yet earned the switch. + */ + private enterSession(part: ParsedPart): boolean { + if (this.key === '') { + this.startSession(part.key, part.total, part.msgLen, part.crc) + return true + } + if (part.key === this.key) { + // A part of the locked session interrupts any foreign-candidate run. + this.candidateKey = '' + this.candidateCount = 0 + return true + } + // Foreign session: never let one stray frame erase progress. Count + // consecutive sightings of the same candidate; a camera genuinely + // pointed at a new sender produces them back to back. + if (part.key === this.candidateKey) this.candidateCount++ + else { + this.candidateKey = part.key + this.candidateCount = 1 + } + if (this.candidateCount < SESSION_SWITCH_PARTS) return false + this.startSession(part.key, part.total, part.msgLen, part.crc) + return true + } + + /** Feed one new in-session part into the peeling state, within budgets. */ + private ingest(part: ParsedPart): AirGapProgress { const indices = part.seq < this.total ? new Set([part.seq]) : new Set(blocksForPart(part.seq, this.total)) const candidate: PendingPart = { indices, payload: part.payload } @@ -239,14 +254,11 @@ export class AirGapDecoder { } this.pending.push(candidate) this.pendingIndices += candidate.indices.size - this.remember(part.seq) - return this.accepted() - } - this.remember(part.seq) - if (candidate.indices.size === 1) { + } else if (candidate.indices.size === 1) { this.solve(candidate) this.cascade() } + this.remember(part.seq) return this.accepted() } diff --git a/packages/helpers/air-gap/tests/airGapCodec.property.test.ts b/packages/helpers/air-gap/tests/airGapCodec.property.test.ts index be279e7aa..38787e1d4 100644 --- a/packages/helpers/air-gap/tests/airGapCodec.property.test.ts +++ b/packages/helpers/air-gap/tests/airGapCodec.property.test.ts @@ -43,6 +43,20 @@ const blockBytes = fc.integer({ min: 1, max: 1200 }) /** An arbitrary but explicit 8-byte session identity. */ const sessionId = fc.uint8Array({ minLength: 8, maxLength: 8 }) +/** The smallest prime ≥ n, for pairing a sender loop with a co-prime mask. */ +function smallestPrimeAtLeast(n: number): number { + for (let candidate = Math.max(2, n); ; candidate++) { + let prime = true + for (let divisor = 2; divisor * divisor <= candidate; divisor++) { + if (candidate % divisor === 0) { + prime = false + break + } + } + if (prime) return candidate + } +} + describe('air-gap wire properties', () => { it('round-trips arbitrary bytes through one systematic cycle', () => { fc.assert( @@ -72,14 +86,15 @@ describe('air-gap wire properties', () => { // A mask that drops every frame is a camera pointed at the floor. if (!mask.includes(true)) return // Otherwise a repeating keep/drop mask stands in for a camera that - // misses frames. The sender loops through its systematic cycle - // (seq wraps over 4K), so recovery is guaranteed eventually even if - // the fountain parts that get through are linearly dependent. - const cycle = 4 * enc.blockCount - const budget = 30 * enc.blockCount + 200 - for (let tick = 0, seen = 0; seen < budget; tick++) { + // misses frames while the sender loops its sequence. The loop + // length is a prime larger than the mask period, so the two can + // never resonate: by CRT every (mask offset, seq) pair occurs + // within mask.length * cycle ticks, which guarantees every + // systematic part is eventually kept and completion is + // deterministic — no matter how pathological the mask. + const cycle = smallestPrimeAtLeast(Math.max(4 * enc.blockCount, mask.length + 1)) + for (let tick = 0; tick < mask.length * cycle; tick++) { if (!mask[tick % mask.length]) continue - seen++ if (dec.accept(enc.partAt(tick % cycle)).done) break } expect(Array.from(dec.message()!)).toEqual(Array.from(bytes)) From ffba997484a46cc2f281f8d4f640ea76e5fb30bc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 21:48:15 +0000 Subject: [PATCH 6/7] fix(ci): build @bsv/air-gap before the TS conformance runner typechecks it conformance/runner/ts now depends on @bsv/air-gap as a devDependency so it can execute the shared transport vectors, but the 'Build TS runner dependencies' step only ever built @bsv/wallet-toolbox's dependency graph. air-gap isn't in that graph, so its dist/ (and therefore its type declarations) never existed when the runner's typecheck ran, failing with 'Cannot find module @bsv/air-gap'. Add it to the build filter. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013VPatJS7hMnrmHJG9iMRQQ --- .github/workflows/conformance.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 4756de79b..1725bdccb 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -26,7 +26,7 @@ jobs: - name: Install deps run: pnpm install --frozen-lockfile --ignore-scripts - name: Build TS runner dependencies - run: pnpm -r --filter '@bsv/wallet-toolbox...' run build + run: pnpm -r --filter '@bsv/wallet-toolbox...' --filter '@bsv/air-gap' run build - name: Check TS conformance runner quality run: | pnpm --filter @bsv/conformance-runner-ts format:check From c2bfbb6dc54233e3a9a34efe692fbe21269c5a0b Mon Sep 17 00:00:00 2001 From: Deggen Date: Thu, 30 Jul 2026 17:14:26 -0500 Subject: [PATCH 7/7] chore(ci): re-run repository health after PR evidence fix Empty commit to re-trigger CI so "Require complete dependency review evidence" evaluates the updated PR body (GitHub job re-runs reuse the original pull_request event payload, so a body-only edit does not take effect until a new run starts).