diff --git a/docs/infrastructure/message-box-server.md b/docs/infrastructure/message-box-server.md index c158d52d7..fe3fc0fa0 100644 --- a/docs/infrastructure/message-box-server.md +++ b/docs/infrastructure/message-box-server.md @@ -2,9 +2,9 @@ id: infra-message-box-server title: 'Message-box Server' kind: infra -version: '1.1.14' -last_updated: '2026-07-25' -last_verified: '2026-07-25' +version: '1.1.39' +last_updated: '2026-08-24' +last_verified: '2026-08-24' review_cadence_days: 30 status: stable tags: [messaging, overlay, store-and-forward, authentication] @@ -70,6 +70,7 @@ all limits, shared state, BRC-105 pricing, memory evidence, and scaling guidance | Variable | Required | Description | | ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------- | | NODE_ENV | No | `development`, `staging`, or `production` | +| BSV_NETWORK | No | `mainnet`, `testnet`, `ttn`, or `teratestnet` (default `mainnet`) | | PORT | No | HTTP/WebSocket port (default 8080; takes precedence) | | HTTP_PORT | No | Compatibility port fallback | | HOSTING_DOMAIN | No | Public domain for overlay advertisement (e.g., `http://localhost:8080`) | @@ -154,10 +155,12 @@ Migrations tracked in `src/migrations/`: - `2025-01-31-001-notification-permissions.ts` – Firebase notification permissions - `2025-01-31-002-device-registrations.ts` – Device registration tracking - `2026-07-26-001-message-permission-scope.ts` – Enforce one box-wide or sender-specific permission per scope +- `2026-07-26-002-list-query-indexes.ts` – Add list-query indexes for sender and recipient access paths +- `2026-08-04-001-resource-safety.ts` – Add message quota, retention, and resource-safety state ## Health checks -- `GET /health` reports process liveness without authentication. +- `GET /health` and `GET /healthz` report process liveness without authentication. - `GET /ready` verifies database connectivity and returns a non-sensitive 503 response while dependencies are unavailable. - Test an authenticated WebSocket handshake separately when live transport is diff --git a/docs/infrastructure/uhrp-server-basic.md b/docs/infrastructure/uhrp-server-basic.md index 29a87568f..e683e68e0 100644 --- a/docs/infrastructure/uhrp-server-basic.md +++ b/docs/infrastructure/uhrp-server-basic.md @@ -2,9 +2,9 @@ id: infra-uhrp-basic title: 'UHRP Server (Basic)' kind: infra -version: '0.1.8' -last_updated: '2026-07-25' -last_verified: '2026-07-25' +version: '0.1.32' +last_updated: '2026-08-24' +last_verified: '2026-08-24' review_cadence_days: 30 status: beta tags: [uhrp, storage, file-server, development, lightweight] @@ -36,7 +36,7 @@ Clients PUT files with authentication, retrieve files via public GET, and query | Type | Requirement | | ----------------- | --------------------------------------------------------------------------------------------------- | | Database | None; filesystem-based storage | -| External services | Wallet Storage (WALLET_STORAGE_URL), ARC (optional for payment transactions) | +| External services | Wallet Storage (WALLET_STORAGE_URL) | | ts-stack packages | @bsv/sdk, @bsv/auth-express-middleware, @bsv/payment-express-middleware, @bsv/wallet-toolbox-client | ## HTTP endpoints @@ -61,7 +61,7 @@ None. | -------------------------- | -------- | --------------------------------------------------------------------------------------------- | | PRICE_PER_GB_MO | No | Monthly storage price per GB (e.g., `0.03`) | | HOSTING_DOMAIN | No | Public domain for server advertisement (e.g., `localhost:8080` or `https://uhrp.example.com`) | -| BSV_NETWORK | No | Target blockchain network (e.g., `mainnet` or `testnet`) | +| BSV_NETWORK | No | `mainnet`, `testnet`, `ttn`, or `teratestnet` (default `mainnet`) | | WALLET_STORAGE_URL | No | Wallet storage endpoint for key derivation (e.g., `https://store-us-1.bsvb.tech`) | | SERVER_PRIVATE_KEY | Yes | 256-bit hex private key for server identity | | HTTP_PORT | No | Express server port (default: 8080) | @@ -105,17 +105,20 @@ Files stored in `./public` or configured data directory. # Build and start npm run build && npm start -# Or as Docker container (lightweight ts-node, no Dockerfile provided) +# Or build the checked-in multi-stage production image +docker build -t uhrp-lite:local . docker run -d \ -e SERVER_PRIVATE_KEY=<256-bit-hex> \ -e HOSTING_DOMAIN=https://uhrp.example.com \ -e HTTP_PORT=8080 \ -v uhrp_data:/app/public \ -p 8080:8080 \ - node-uhrp-server:latest + uhrp-lite:local ``` -No docker-compose.yml or nginx.conf provided; filesystem-based, no external database. Direct Express server on configured port. +The Dockerfile compiles TypeScript in a disposable Node 24 build stage, runs +the built server as the unprivileged `node` user, and probes `/ready`. +No compose file or external database is required. ## Migrations @@ -123,7 +126,11 @@ None; stateless server with files stored directly on disk with JSON metadata. ## Health checks -Implicit health via GET / returning HTTP 200. No explicit health endpoint. Monitor disk space and file directory accessibility. +- `GET /health` and `GET /healthz` report process liveness. +- `GET /ready` returns 200 only after startup completes and 503 while the + process is starting or shutting down. +- The container health check probes `/ready`. Operators must additionally + monitor disk space and object-directory accessibility. ## Spec conformance diff --git a/docs/infrastructure/uhrp-server-cloud-bucket.md b/docs/infrastructure/uhrp-server-cloud-bucket.md index 778719268..22e993766 100644 --- a/docs/infrastructure/uhrp-server-cloud-bucket.md +++ b/docs/infrastructure/uhrp-server-cloud-bucket.md @@ -2,9 +2,9 @@ id: infra-uhrp-cloud title: 'UHRP Server (Cloud Bucket)' kind: infra -version: '0.2.10' -last_updated: '2026-07-25' -last_verified: '2026-07-25' +version: '0.2.34' +last_updated: '2026-08-24' +last_verified: '2026-08-24' review_cadence_days: 30 status: stable tags: [uhrp, storage, cloud, google-cloud-run, production] @@ -12,7 +12,7 @@ tags: [uhrp, storage, cloud, google-cloud-run, production] # UHRP Server (Cloud Bucket) -> A production-grade UHRP host server backed by Google Cloud Storage (or S3-compatible buckets). Stores large files in cloud buckets with optional billing/micropayments and includes advertising infrastructure for overlay network discovery. +> A production-grade UHRP host server backed by Google Cloud Storage. Stores large files in a cloud bucket with billing/micropayment support and notifier-driven advertising for overlay network discovery. ## What it does @@ -21,7 +21,8 @@ workflows backed by Google Cloud Storage. Static object retrieval is public; upload, list, find, and renewal require BRC-103 identity. A separate administrative advertisement endpoint uses a strong Bearer token. -Clients upload files with authentication, retrieve files via public GET, and server continuously advertises hosting capability. +Clients request authenticated uploads, retrieve files via public GET, and use +the bucket notifier to trigger authenticated hosting advertisements. ## When to deploy this @@ -35,8 +36,8 @@ Clients upload files with authentication, retrieve files via public GET, and ser | Type | Requirement | | ----------------- | ------------------------------------------------------------------------------------------------------------------------ | -| Database | Optional MySQL via Knex (for backup storage or metadata tracking); not required if using cloud-only | -| External services | Google Cloud Storage bucket, ARC API key, Wallet Storage, Bugsnag (optional) | +| Database | None; Google Cloud Storage is the object and metadata store | +| External services | Google Cloud Storage bucket and Wallet Storage | | ts-stack packages | @bsv/sdk, @bsv/auth-express-middleware, @bsv/payment-express-middleware, @bsv/wallet-toolbox, @bsv/wallet-toolbox-client | ## HTTP endpoints @@ -63,16 +64,13 @@ None; HTTP-only with background advertising worker. | NODE_ENV | No | `development`, `staging`, or `production` | | SERVER_PRIVATE_KEY | Yes | 256-bit hex private key for server identity | | HOSTING_DOMAIN | No | Public HTTPS domain for advertising (e.g., `https://uhrp-storage.example.com`) | -| BSV_NETWORK | No | Target blockchain network (`main`, `test`, or `regtest`) | +| BSV_NETWORK | No | `mainnet`, `testnet`, `ttn`, or `teratestnet` (default `mainnet`) | | WALLET_STORAGE_URL | No | Wallet storage endpoint (e.g., `https://store-us-1.bsvb.tech`) | | PRICE_PER_GB_MO | No | Monthly storage price per GB for billing | -| ENABLE_PAYMENT_MIDDLEWARE | No | Set to `'true'` to require payment for uploads | -| GOOGLE_CLOUD_PROJECT | No | GCP project ID (auto-detected from service account if available) | -| GOOGLE_CLOUD_BUCKET | Yes | Cloud Storage bucket name (e.g., `uhrp-storage-prod`) | -| GOOGLE_APPLICATION_CREDENTIALS | No | Path to service account JSON key (for local/Cloud Run auth) | -| ARC_API_KEY | No | ARC API key for transaction broadcasting (advertising) | -| ADVERTISE_INTERVAL_MS | No | Interval for re-advertising to overlay (default: 3600000ms = 1 hour) | -| BUGSNAG_API_KEY | No | Bugsnag error reporting API key (optional) | +| MIN_HOSTING_MINUTES | No | Minimum requested retention period (default 180 minutes) | +| GCP_PROJECT_ID | Yes* | GCP project used for production signed upload URLs | +| GCP_BUCKET_NAME | Yes | Cloud Storage bucket name (e.g., `uhrp-storage-prod`) | +| GCP_STORAGE_CREDS | Yes* | JSON credentials used for production signed upload URLs; provide through a secret | | ADMIN_TOKEN | Yes | At least 32 random characters for `/advertise` Bearer auth | | UHRP_CORS_MODE | No | `public` (default), `allowlist`, or `disabled` | | UHRP_CORS_ALLOWED_ORIGINS | No | Exact comma-separated origins in allowlist mode | @@ -80,6 +78,9 @@ None; HTTP-only with background advertising worker. | UHRP_JSON_MAX_BODY_BYTES | No | JSON body ceiling (default 262144) | | TRUST_PROXY_HOPS | No | Exact trusted proxy hop count, 0 through 10 | +`GCP_PROJECT_ID` and `GCP_STORAGE_CREDS` are required by the production +signed-upload path; the development path returns a local placeholder URL. + See [Public Service Edge Security](service-edge-security.md#uhrp-cloud-bucket-server) for full edge controls. @@ -112,7 +113,7 @@ gcloud run deploy uhrp-storage \ --image uhrp-storage:latest \ --platform managed \ --region us-central1 \ - --set-env-vars SERVER_PRIVATE_KEY=,GOOGLE_CLOUD_BUCKET=uhrp-storage-prod,ENABLE_PAYMENT_MIDDLEWARE=true + --set-env-vars SERVER_PRIVATE_KEY=,GCP_PROJECT_ID=,GCP_BUCKET_NAME=uhrp-storage-prod,ADMIN_TOKEN=<32+-character-token> # Or deploy with docker-compose (local testing only) docker compose up -d @@ -122,34 +123,38 @@ Follows GCP 12-factor patterns: stateless design, cloud bucket for file storage, ## Migrations -Stateless; cloud bucket is source of truth. Optional MySQL Knex migrations for metadata tables if ENABLE_METADATA_DB=true. +None. The cloud bucket and object metadata are the storage authority. ## Health checks -Implicit health via /info endpoint (HTTP 200). Cloud Run readiness probe typically checks GET /info or GET /{hash} availability. No explicit /healthz endpoint. +- `GET /health` and `GET /healthz` report process liveness. +- `GET /ready` returns 200 after initialization and 503 while the process is + starting or shutting down. +- The checked-in container health check probes `/ready`. ## Spec conformance - **UHRP** – Implements UHRP host protocol for file storage, retrieval, and metadata -- **BRC-103** – Mutual authentication on PUT, optional on GET/POST -- **BRC-100** – Payment verification for uploads (optional) +- **BRC-103** – Mutual authentication on upload, list, find, and renewal workflows +- **BRC-100** – Wallet-backed pricing and payment verification for upload and renewal - **Google Cloud** – Follows Cloud Run best practices (health checks, graceful shutdown, 12-factor) ## Integration with ts-stack - UHRP clients upload/retrieve files using SERVER_PRIVATE_KEY and HOSTING_DOMAIN - Wallet Storage derives keys, validates payments, manages user accounts -- Background worker advertises UHRP host via SHIP overlay protocol using ARC broadcaster -- Optional Cloud SQL metadata database for query optimization -- Bugsnag integration for production error tracking and monitoring +- The bucket notifier calls the token-protected `/advertise` route, which + publishes the UHRP advertisement through the SDK SHIP broadcaster ## Common pitfalls - GCP credentials: GOOGLE_APPLICATION_CREDENTIALS must point to valid service account JSON; Cloud Run uses default service account if not set - Storage bucket policy: Ensure bucket exists and service account has storage.objects.create/get/delete permissions - Cost management: Monitor storage usage and pricing; use Cloud Storage lifecycle policies for archival -- Payment enforcement: ENABLE_PAYMENT_MIDDLEWARE requires ARC_API_KEY and WALLET_STORAGE_URL; uploads fail if not configured -- Advertising loop: ADVERTISE_INTERVAL_MS should balance frequent updates vs transaction costs; 1 hour is conservative default +- Signed uploads: `GCP_PROJECT_ID`, `GCP_BUCKET_NAME`, and valid JSON in + `GCP_STORAGE_CREDS` must agree; malformed credentials fail URL creation +- Advertising: `ADMIN_TOKEN` must match the bucket notifier and contain at + least 32 characters - Cloud Run and application request timeouts default to 60 seconds; use direct cloud upload workflows for large objects rather than unbounded application buffering - Graceful shutdown: Cloud Run sends SIGTERM; ensure all writes complete before exit (transaction broadcasts, metadata flushes) diff --git a/docs/packages/wallet/ecpm-permission-module.md b/docs/packages/wallet/ecpm-permission-module.md new file mode 100644 index 000000000..c2ff5abbf --- /dev/null +++ b/docs/packages/wallet/ecpm-permission-module.md @@ -0,0 +1,104 @@ +--- +id: ecpm-permission-module +title: '@bsv/ecpm-permission-module' +kind: package +domain: wallet +npm: '@bsv/ecpm-permission-module' +version: '0.1.0' +last_updated: '2026-08-24' +last_verified: '2026-08-24' +review_cadence_days: 30 +status: experimental +tags: ['permissions', 'brc98', 'ecpm', 'cryptography'] +repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ecpm-permission-module' +--- + +# @bsv/ecpm-permission-module + +`@bsv/ecpm-permission-module` is the reference `p ecpm` semantic module for +BRC-100 wallet hosts. It applies or removes a wallet-derived scalar from an +arbitrary validated secp256k1 point while retaining the standard +`getPublicKey` request and response shapes. + +Use this package when a protocol needs commutative point masking or another +point operation whose base is supplied by the application. Pure BRC-43 can +name a counterparty while deriving the scalar, but ordinary `getPublicKey` +still returns that scalar times the fixed generator; it cannot select the +caller's point as the multiplication base. + +## Install + +```bash +npm install @bsv/ecpm-permission-module @bsv/wallet-toolbox-client @bsv/sdk +``` + +## Protocol + +Call the existing `getPublicKey` method with this protocol name inside the +normal `[securityLevel, protocolName]` tuple: + +```text +p ecpm +``` + +The module reads `keyID`, `counterparty`, `privileged`, `privilegedReason`, and +`seekPermission` from their existing fields. It derives the scalar under +`p ecpm `; the operation and point are deliberately omitted +so `remove` uses the inverse of the exact scalar selected by `apply`. + +```typescript +const masked = await wallet.getPublicKey({ + protocolID: [2, `p ecpm apply ${pointHex} mental poker deal`], + keyID: 'deck mask', + counterparty: 'self' +}) + +const restored = await wallet.getPublicKey({ + protocolID: [2, `p ecpm remove ${masked.publicKey} mental poker deal`], + keyID: 'deck mask', + counterparty: 'self' +}) +``` + +## Wallet installation + +```typescript +import { createEcpmModule } from '@bsv/ecpm-permission-module' +import { WalletPermissionsManager } from '@bsv/wallet-toolbox-client' + +const ecpm = createEcpmModule({ + keyDeriver: setup.keyDeriver, + authorize: request => showTrustedWalletPrompt(request), + privilegedKeyDeriver: reason => acquirePrivilegedKeyDeriver(reason) +}) + +const wallet = new WalletPermissionsManager(setup.wallet, adminOriginator, { + permissionModules: { ecpm } +}) +``` + +The privileged provider is optional. If an application requests +`privileged: true`, the module authorizes the supplied reason before asking the +host for a privileged deriver and fails closed when no provider is available. + +## Security and permissions + +- Only `getPublicKey` is accepted under `p ecpm`; signing, HMAC, and encryption + calls cannot reuse the ECPM-derived scalar. +- Identity-key and `forSelf` modes are rejected. +- Input points and public-key counterparties must be canonical lowercase, + compressed, finite secp256k1 points. +- Security level 0 ordinary calls do not prompt. Levels 1 and 2 require the + authorization callback; level 2 grants are scoped to the counterparty. +- Every privileged call requires authorization, and `seekPermission: false` + fails unless an applicable grant is already cached. +- The application receives only `{ publicKey }`, never the derived scalar or + either key-derivation provider. + +## Module interface + +Wallet Toolbox exposes the optional +`PermissionsModule.handleRequest(request, next)` semantic hook. A module can +return the standard BRC-100 result directly, as ECPM does, or call `next` at +most once. Existing `onRequest`/`onResponse` transformation modules remain +compatible. diff --git a/docs/packages/wallet/index.md b/docs/packages/wallet/index.md index 8626578b6..58e095404 100644 --- a/docs/packages/wallet/index.md +++ b/docs/packages/wallet/index.md @@ -26,6 +26,7 @@ The wallet domain builds on top of [@bsv/sdk](../sdk/bsv-sdk.md). If you only ne | [@bsv/wallet-toolbox-mobile](./wallet-toolbox-mobile.md) | React Native/mobile-safe wallet and remote storage distribution | | [@bsv/btms](./btms.md) | UTXO-based token issuance, transfer, burning, and ownership proof validation | | [@bsv/btms-permission-module](./btms-permission-module.md) | Framework-agnostic BRC-98/99 permission hooks for BTMS token spending with custom UI callback | +| [@bsv/ecpm-permission-module](./ecpm-permission-module.md) | BRC-98 semantic module for applying and removing wallet-derived scalars from secp256k1 points | | [@bsv/wallet-relay](./wallet-relay.md) | Mobile-to-desktop wallet pairing via QR codes and encrypted WebSocket relay with React components | ## Common Use Cases @@ -54,6 +55,13 @@ Use [@bsv/wallet-relay](./wallet-relay.md) for QR pairing. Desktop shows QR, mob Use [@bsv/btms-permission-module](./btms-permission-module.md) with your custom permission handler (modal, alert, web component, etc.). +### I need wallet-native point multiplication + +Use [@bsv/ecpm-permission-module](./ecpm-permission-module.md) to install the +`p ecpm` scheme. It reuses `getPublicKey`, so applications can apply or remove +a BRC-42/43-derived scalar from a named point without extending the BRC-100 +wallet interface or exposing the scalar. + ## Key Concepts - **BRC-100 Wallet Interface** — Standard interface implemented by all wallet packages. Apps can work with any wallet (desktop, mobile, hardware) without code changes. @@ -66,6 +74,7 @@ Use [@bsv/btms-permission-module](./btms-permission-module.md) with your custom - **Ownership Proof** — Cryptographic proof of token ownership without revealing private key. Used for collateral, escrow, access control. - **Relay Session** — Encrypted tunnel between desktop and mobile wallet. QR encodes relay URL + session ID; mobile scans and establishes WebSocket connection. - **Permission Module** — BRC-98/99 hooks that intercept special operations (token spend, burn) and prompt user via custom callback. +- **Semantic Permission Module** — A BRC-98 module that owns a P-scheme's meaning and returns a conforming result without requiring the underlying BRC-100 method to retain its ordinary behavior. ## Architecture Overview @@ -80,6 +89,7 @@ Use [@bsv/btms-permission-module](./btms-permission-module.md) with your custom | **wallet-toolbox-mobile** | Build tooling | — | Remote | | **btms** | ✓ | ✓ | ✓ | | **btms-permission-module** | ✓ | ✓ | ✓ | +| **ecpm-permission-module** | ✓ | ✓ | Host-dependent | | **wallet-relay** | Server | React components | Supported via relay | ## When to Use Each Package diff --git a/docs/packages/wallet/wallet-toolbox-client.md b/docs/packages/wallet/wallet-toolbox-client.md index d77a65565..ebf74b70a 100644 --- a/docs/packages/wallet/wallet-toolbox-client.md +++ b/docs/packages/wallet/wallet-toolbox-client.md @@ -3,7 +3,7 @@ id: pkg-wallet-toolbox-client title: '@bsv/wallet-toolbox-client' kind: package domain: wallet -version: '2.10.2' +version: '2.11.0' last_updated: '2026-08-14' last_verified: '2026-08-14' review_cadence_days: 30 diff --git a/docs/packages/wallet/wallet-toolbox-mobile.md b/docs/packages/wallet/wallet-toolbox-mobile.md index 861bb427a..260dade9c 100644 --- a/docs/packages/wallet/wallet-toolbox-mobile.md +++ b/docs/packages/wallet/wallet-toolbox-mobile.md @@ -3,7 +3,7 @@ id: pkg-wallet-toolbox-mobile title: '@bsv/wallet-toolbox-mobile' kind: package domain: wallet -version: '2.10.2' +version: '2.11.0' last_updated: '2026-08-14' last_verified: '2026-08-14' review_cadence_days: 30 diff --git a/docs/packages/wallet/wallet-toolbox.md b/docs/packages/wallet/wallet-toolbox.md index c26021863..c6afed2de 100644 --- a/docs/packages/wallet/wallet-toolbox.md +++ b/docs/packages/wallet/wallet-toolbox.md @@ -4,7 +4,7 @@ title: '@bsv/wallet-toolbox' kind: package domain: wallet npm: '@bsv/wallet-toolbox' -version: '2.10.2' +version: '2.11.0' last_updated: '2026-08-14' last_verified: '2026-08-14' review_cadence_days: 30 @@ -128,6 +128,18 @@ console.log(publicKey) `setup.wallet` is the BRC-100 wallet. The surrounding `setup` object exposes the constructed `rootKey`, `identityKey`, `keyDeriver`, `storage`, `services`, and `monitor` so wallet builders can inspect or replace pieces while developing. +## Permission modules + +`WalletPermissionsManager` registers BRC-98/99/111 modules by the scheme after +the `p` prefix. Existing modules can transform calls with `onRequest` and +`onResponse`. A semantic module can instead implement +`handleRequest(request, next)` and return the conforming BRC-100 result itself; +if it needs the underlying wallet operation, `next` is guarded to one call. + +The separate [@bsv/ecpm-permission-module](./ecpm-permission-module.md) uses +this hook to implement point multiplication under `p ecpm` while keeping +`getPublicKey` as the public wallet method. + ## Action Flow When every input can be signed by the wallet, `createAction` can return a completed action: diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index f3b093b6c..7ca527376 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 31 public manifests, package documentation, and +This page is generated from all 32 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. @@ -36,6 +36,7 @@ and clean-consumer tests remain the executable type authority. | `@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.3.0` | minor | [API and usage](../packages/helpers/did-client.md) | Existing local, mainnet, and testnet behavior is unchanged. TTN consumers select networkPreset teratestnet and use @bsv/sdk 2.4 or later. | +| `@bsv/ecpm-permission-module` | `0.0.0` | `0.1.0` | minor | [API and usage](../packages/wallet/ecpm-permission-module.md) | No existing consumer migration is required; this is the first release. Wallet hosts register the module under the ecpm scheme and supply their ordinary key deriver, authorization handler, and optional privileged key provider. | | `@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.4.0` | `2.4.1` | patch | [API and usage](../packages/messaging/message-box-client.md) | No API migration is required. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. | @@ -52,9 +53,9 @@ and clean-consumer tests remain the executable type authority. | `@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.5` | minor | [API and usage](../packages/wallet/wallet-relay.md) | No wallet RPC migration is required; upgrade to @bsv/sdk 2.4.1 or later. Existing relay sessions and number arrays remain valid, and host applications continue to provide their matching Express runtime and type graph. | -| `@bsv/wallet-toolbox` | `2.10.0` | `2.10.2` | patch | [API and usage](../packages/wallet/wallet-toolbox.md) | No consumer migration is required. Canonical AtomicBEEF and existing number-array behavior are unchanged; upgrade to @bsv/sdk 2.4.1 or later for cross-version JSON and wallet-error compatibility. | -| `@bsv/wallet-toolbox-client` | `2.10.0` | `2.10.2` | patch | [API and usage](../packages/wallet/wallet-toolbox-client.md) | No consumer migration is required. Browser exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.1 or later. | -| `@bsv/wallet-toolbox-mobile` | `2.10.0` | `2.10.2` | patch | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | No consumer migration is required. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.1 or later. | +| `@bsv/wallet-toolbox` | `2.10.0` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox.md) | Existing permission modules require no changes because onRequest and onResponse remain supported. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. | +| `@bsv/wallet-toolbox-client` | `2.10.0` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-client.md) | Existing permission modules require no changes. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registering it under the ecpm scheme. | +| `@bsv/wallet-toolbox-mobile` | `2.10.0` | `2.11.0` | minor | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | Existing permission modules require no changes. Semantic modules may add handleRequest; mobile hosts can install and register compatible semantic modules without changing the Wallet interface. | | `create-bsv-app` | `1.0.2` | `1.1.0` | minor | [API and usage](../packages/helpers/create-bsv-app.md) | Existing mainnet and testnet scaffolds are unchanged. New TTN projects pass --network ttn or select TerraTestNet in the configurator. | `none` means the source manifest matches the recorded npm baseline. Any other @@ -193,6 +194,17 @@ explicitly authorized operations. | `./*.ts` | `./dist/src/*.js`
`./dist/src/*.cjs` | `./dist/src/*.d.ts`
`./dist/src/*.d.cts` | | `./package.json` | `./package.json` | — | +## @bsv/ecpm-permission-module + +- Package documentation: [docs/packages/wallet/ecpm-permission-module.md](../packages/wallet/ecpm-permission-module.md) +- Source: [packages/wallet/ecpm-permission-module](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ecpm-permission-module) +- Release note: Introduces the BRC-98 p ecpm semantic module, applying or removing a wallet-derived scalar from validated secp256k1 points through the existing BRC-100 getPublicKey surface, including scoped permission and privileged-key-provider hooks. +- Migration: No existing consumer migration is required; this is the first release. Wallet hosts register the module under the ecpm scheme and supply their ordinary key deriver, authorization handler, and optional privileged key provider. + +| Public subpath | Runtime target(s) | Declaration target(s) | +| -------------- | ------------------ | --------------------- | +| `.` | `./dist/index.mjs` | `./dist/index.d.mts` | + ## @bsv/fund-wallet - Package documentation: [docs/packages/helpers/fund-wallet.md](../packages/helpers/fund-wallet.md) @@ -477,8 +489,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox.md](../packages/wallet/wallet-toolbox.md) - Source: [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) -- Release note: Retains BRC-95 internalization compatibility, preserves typed AtomicBEEF, competing BEEF, and wallet review errors across portable and historical wallet JSON representations, and leaves opaque WAB response JSON unchanged. -- Migration: No consumer migration is required. Canonical AtomicBEEF and existing number-array behavior are unchanged; upgrade to @bsv/sdk 2.4.1 or later for cross-version JSON and wallet-error compatibility. +- Release note: Adds an optional semantic handleRequest hook to BRC-98/99/111 permission modules, allowing a module to return a conforming BRC-100 result or safely invoke the underlying operation once, while retaining the current transformation hooks and recent wallet compatibility fixes. +- Migration: Existing permission modules require no changes because onRequest and onResponse remain supported. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ---------------------------------------------------- | -------------------------- | @@ -491,8 +503,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-client.md](../packages/wallet/wallet-toolbox-client.md) - Source: [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) -- Release note: Carries the browser Wallet Toolbox internalization and BRC-100 JSON byte-boundary compatibility fixes, including portable wallet review errors and historical response recovery. -- Migration: No consumer migration is required. Browser exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.1 or later. +- Release note: Exports the optional semantic handleRequest permission-module hook for browser and ESM wallet hosts while retaining existing transformation modules and BRC-100 wire compatibility. +- Migration: Existing permission modules require no changes. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registering it under the ecpm scheme. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | @@ -503,8 +515,8 @@ CLI entry points: `{"wallet-relay":"./bin/init.mjs"}`. - Package documentation: [docs/packages/wallet/wallet-toolbox-mobile.md](../packages/wallet/wallet-toolbox-mobile.md) - Source: [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) -- Release note: Carries the mobile Wallet Toolbox internalization and BRC-100 JSON byte-boundary compatibility fixes, including portable wallet review errors and historical response recovery. -- Migration: No consumer migration is required. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.1 or later. +- Release note: Exports the optional semantic handleRequest permission-module hook for React Native wallet hosts while retaining existing transformation modules and BRC-100 wire compatibility. +- Migration: Existing permission modules require no changes. Semantic modules may add handleRequest; mobile hosts can install and register compatible semantic modules without changing the Wallet interface. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | diff --git a/docs/reference/release-2026-07-25.md b/docs/reference/release-2026-07-25.md index bf5b905c7..b9c1a1dbf 100644 --- a/docs/reference/release-2026-07-25.md +++ b/docs/reference/release-2026-07-25.md @@ -4,7 +4,7 @@ title: "July 2026 Stack Modernization Release" kind: reference version: "1.0.0" last_updated: "2026-07-25" -last_verified: "2026-07-25" +last_verified: "2026-08-24" review_cadence_days: 30 status: stable tags: [reference, release, compatibility, security] diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index aa3585130..89ffefc0a 100644 --- a/docs/reference/stack-facts.md +++ b/docs/reference/stack-facts.md @@ -31,7 +31,7 @@ Node consumers; they do not require a browser or mobile device to provide Node A ## Public package manifest -The release graph currently contains **31 public packages**. Versions +The release graph currently contains **32 public packages**. Versions below are source-manifest versions; registry publication is a separate, explicitly authorized release action. @@ -64,10 +64,11 @@ authorized release action. | sdk | `@bsv/verifast` | `0.3.4` | wasm-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global, wasm-worker | browser, node, umd, wasm, worker | `>=22` | [packages/verifast](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/verifast) | | wallet | `@bsv/btms` | `1.2.1` | node-library | node-cjs, node-esm | node | `>=22` | [packages/wallet/btms](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms) | | wallet | `@bsv/btms-permission-module` | `1.1.3` | node-library | node-esm | node | `>=22` | [packages/wallet/btms-permission-module](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms-permission-module) | +| wallet | `@bsv/ecpm-permission-module` | `0.1.0` | browser-library | browser-bundler, browser-esm, node-esm | browser, node | `>=22` | [packages/wallet/ecpm-permission-module](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ecpm-permission-module) | | wallet | `@bsv/wallet-relay` | `0.3.5` | cli-library | browser-bundler, browser-esm, cli, node-cjs, node-esm | browser, node | `>=22` | [packages/wallet/ts-wallet-relay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ts-wallet-relay) | -| wallet | `@bsv/wallet-toolbox` | `2.10.2` | node-library | node-cjs | node | `>=22` | [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) | -| wallet | `@bsv/wallet-toolbox-client` | `2.10.2` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) | -| wallet | `@bsv/wallet-toolbox-mobile` | `2.10.2` | react-native-library | react-native-metro | react-native | `>=22` | [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) | +| wallet | `@bsv/wallet-toolbox` | `2.11.0` | node-library | node-cjs | node | `>=22` | [packages/wallet/wallet-toolbox](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox) | +| wallet | `@bsv/wallet-toolbox-client` | `2.11.0` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/wallet/wallet-toolbox/client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client) | +| wallet | `@bsv/wallet-toolbox-mobile` | `2.11.0` | react-native-library | react-native-metro | react-native | `>=22` | [packages/wallet/wallet-toolbox/mobile](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile) | ## Standalone infrastructure manifests @@ -88,9 +89,9 @@ the separately released and verified image digest. | Metric | Count | | --- | --- | -| Governed projects | 38 | -| Package-area projects | 34 | -| Public npm packages | 31 | +| Governed projects | 39 | +| Package-area projects | 35 | +| Public npm packages | 32 | | Private package-area projects | 3 | | Standalone infrastructure projects | 7 | @@ -125,7 +126,7 @@ targets have been completed. | Metric | Current value | Authority | | --- | --- | --- | -| Projects with a test:coverage script | 33 | current package manifests | +| Projects with a test:coverage script | 34 | 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/browser-artifact-policy.json b/governance/browser-artifact-policy.json index c22e83810..4b60681af 100644 --- a/governance/browser-artifact-policy.json +++ b/governance/browser-artifact-policy.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "lastReviewed": "2026-08-04", + "lastReviewed": "2026-08-24", "owner": "ts-stack-maintainers", "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.", @@ -96,6 +96,13 @@ "entry": "./client", "splittingDisposition": "The client subpath excludes Express and the server-only QR adapter; qrcode is dynamically imported only by the server session manager." }, + { + "name": "@bsv/ecpm-permission-module", + "path": "packages/wallet/ecpm-permission-module", + "budget": "packages/wallet/ecpm-permission-module/browser-budget.json", + "entry": ".", + "splittingDisposition": "The ESM entry is a single semantic wallet module; it imports only the public secp256k1 primitives from @bsv/sdk, while wallet-toolbox interfaces are type-only and no host, server, UI, or privileged-key adapter is bundled." + }, { "name": "@bsv/wallet-toolbox-client", "path": "packages/wallet/wallet-toolbox/client", diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index 3c9f4fe42..55d3acc01 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -316,6 +316,16 @@ "minimumScore": 82, "maximumNoCoverage": 0, "maximumInvalid": 0 + }, + { + "id": "ecpm-permission", + "manifest": "packages/wallet/ecpm-permission-module/package.json", + "propertyTest": "packages/wallet/ecpm-permission-module/src/__tests__/EcpmPermissionModule.property.test.ts", + "risk": "critical", + "boundary": "Wallet-derived secret scalars applied to untrusted caller-supplied secp256k1 points", + "minimumScore": 85, + "maximumNoCoverage": 0, + "maximumInvalid": 0 } ] } diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 3926f6d67..3b2c17140 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -497,6 +497,22 @@ export function buildMutationTargets(repositoryRoot) { ...jestTarget('jest.config.cjs', ['/src/__tests__/BasicTokenModule*.test.ts'], { esm: true }) + }, + 'ecpm-permission': { + packageDirectory: 'packages/wallet/ecpm-permission-module', + manifest: 'packages/wallet/ecpm-permission-module/package.json', + propertyTest: + 'packages/wallet/ecpm-permission-module/src/__tests__/EcpmPermissionModule.property.test.ts', + // Keep the defensive infinity checks in production, but omit them from mutation: + // canonical compressed points and nonzero PrivateKey scalars cannot reach either branch. + mutate: [ + 'src/EcpmPermissionModule.ts:35-198', + 'src/EcpmPermissionModule.ts:202-284', + 'src/EcpmPermissionModule.ts:288-293' + ], + ...jestTarget('jest.config.cjs', ['/src/__tests__/EcpmPermissionModule*.test.ts'], { + esm: true + }) } } } diff --git a/governance/npm-package-supply-chain.json b/governance/npm-package-supply-chain.json index 0e81b2ff0..a4f0114e8 100644 --- a/governance/npm-package-supply-chain.json +++ b/governance/npm-package-supply-chain.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "artifactSchemaVersion": 1, - "publicPackageCount": 31, + "publicPackageCount": 32, "releaseWorkflow": ".github/workflows/release.yaml", "releaseEnvironment": "npm-production", "buildRuntime": { diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 393ed8dea..e1c5f4943 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -66,6 +66,13 @@ "summary": "Adopts the governed strict TypeScript profile and repository-wide zero-warning lint and formatting contract.", "migration": "No consumer migration is required; permission-module APIs and token semantics are unchanged." }, + { + "name": "@bsv/ecpm-permission-module", + "publishedVersion": "0.0.0", + "releaseType": "minor", + "summary": "Introduces the BRC-98 p ecpm semantic module, applying or removing a wallet-derived scalar from validated secp256k1 points through the existing BRC-100 getPublicKey surface, including scoped permission and privileged-key-provider hooks.", + "migration": "No existing consumer migration is required; this is the first release. Wallet hosts register the module under the ecpm scheme and supply their ordinary key deriver, authorization handler, and optional privileged key provider." + }, { "name": "@bsv/did", "publishedVersion": "0.2.1", @@ -195,23 +202,23 @@ { "name": "@bsv/wallet-toolbox", "publishedVersion": "2.10.0", - "releaseType": "patch", - "summary": "Retains BRC-95 internalization compatibility, preserves typed AtomicBEEF, competing BEEF, and wallet review errors across portable and historical wallet JSON representations, and leaves opaque WAB response JSON unchanged.", - "migration": "No consumer migration is required. Canonical AtomicBEEF and existing number-array behavior are unchanged; upgrade to @bsv/sdk 2.4.1 or later for cross-version JSON and wallet-error compatibility." + "releaseType": "minor", + "summary": "Adds an optional semantic handleRequest hook to BRC-98/99/111 permission modules, allowing a module to return a conforming BRC-100 result or safely invoke the underlying operation once, while retaining the current transformation hooks and recent wallet compatibility fixes.", + "migration": "Existing permission modules require no changes because onRequest and onResponse remain supported. Semantic modules may add handleRequest; hosts installing @bsv/ecpm-permission-module register it under the ecpm scheme." }, { "name": "@bsv/wallet-toolbox-client", "publishedVersion": "2.10.0", - "releaseType": "patch", - "summary": "Carries the browser Wallet Toolbox internalization and BRC-100 JSON byte-boundary compatibility fixes, including portable wallet review errors and historical response recovery.", - "migration": "No consumer migration is required. Browser exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.1 or later." + "releaseType": "minor", + "summary": "Exports the optional semantic handleRequest permission-module hook for browser and ESM wallet hosts while retaining existing transformation modules and BRC-100 wire compatibility.", + "migration": "Existing permission modules require no changes. Semantic modules may add handleRequest; installing @bsv/ecpm-permission-module requires registering it under the ecpm scheme." }, { "name": "@bsv/wallet-toolbox-mobile", "publishedVersion": "2.10.0", - "releaseType": "patch", - "summary": "Carries the mobile Wallet Toolbox internalization and BRC-100 JSON byte-boundary compatibility fixes, including portable wallet review errors and historical response recovery.", - "migration": "No consumer migration is required. React Native exports, wire types, and canonical AtomicBEEF behavior are unchanged; use @bsv/sdk 2.4.1 or later." + "releaseType": "minor", + "summary": "Exports the optional semantic handleRequest permission-module hook for React Native wallet hosts while retaining existing transformation modules and BRC-100 wire compatibility.", + "migration": "Existing permission modules require no changes. Semantic modules may add handleRequest; mobile hosts can install and register compatible semantic modules without changing the Wallet interface." }, { "name": "create-bsv-app", diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index dedb65461..d9f18b8ae 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": 38, - "packageAreaProjects": 34, - "publicPackages": 31, + "projects": 39, + "packageAreaProjects": 35, + "publicPackages": 32, "privatePackageAreaProjects": 3 }, "ci": { @@ -324,9 +324,10 @@ "@bsv/verifast": "0.3.4", "@bsv/btms": "1.2.1", "@bsv/btms-permission-module": "1.1.3", + "@bsv/ecpm-permission-module": "0.1.0", "@bsv/wallet-relay": "0.3.5", - "@bsv/wallet-toolbox-client": "2.10.2", - "@bsv/wallet-toolbox-mobile": "2.10.2", - "@bsv/wallet-toolbox": "2.10.2" + "@bsv/wallet-toolbox-client": "2.11.0", + "@bsv/wallet-toolbox-mobile": "2.11.0", + "@bsv/wallet-toolbox": "2.11.0" } } diff --git a/governance/repository-health/projects.json b/governance/repository-health/projects.json index e51c56ae7..0f26c37d3 100644 --- a/governance/repository-health/projects.json +++ b/governance/repository-health/projects.json @@ -714,6 +714,17 @@ "runtimeTargets": ["node"], "release": "npm-oidc" }, + { + "path": "packages/wallet/ecpm-permission-module", + "name": "@bsv/ecpm-permission-module", + "owner": "ts-stack-maintainers", + "area": "wallet", + "profile": "browser-library", + "consumerProfiles": ["browser-bundler", "browser-esm", "node-esm"], + "criticality": "tier-1", + "runtimeTargets": ["browser", "node"], + "release": "npm-oidc" + }, { "path": "packages/wallet/ts-wallet-relay", "name": "@bsv/wallet-relay", diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index 0ec37e7db..59aa8769f 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -40,7 +40,8 @@ "packages/helpers/create-bsv-app/package.json", "packages/overlays/gasp-core/package.json", "packages/overlays/btms-backend/package.json", - "packages/wallet/btms-permission-module/package.json" + "packages/wallet/btms-permission-module/package.json", + "packages/wallet/ecpm-permission-module/package.json" ], "suites": [ { @@ -423,6 +424,17 @@ "Session approval is cached independently for each arbitrary originator.", "Array-shaped request arguments are rejected at the authorization boundary." ] + }, + { + "path": "packages/wallet/ecpm-permission-module/src/__tests__/EcpmPermissionModule.property.test.ts", + "manifest": "packages/wallet/ecpm-permission-module/package.json", + "risk": "critical", + "boundary": "Wallet-derived secret scalars applied to untrusted caller-supplied secp256k1 points", + "target": "BRC-98 ECPM apply/remove inversion across independently generated wallet roots and finite curve points", + "invariants": [ + "Applying and then removing one module-derived scalar returns every generated finite input point exactly.", + "Neither randomized root material nor the corresponding derived scalar is exposed in the wallet result." + ] } ], "exclusions": [ diff --git a/packages/wallet/ecpm-permission-module/AGENTS.md b/packages/wallet/ecpm-permission-module/AGENTS.md new file mode 100644 index 000000000..dcea67c80 --- /dev/null +++ b/packages/wallet/ecpm-permission-module/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/wallet/ecpm-permission-module/LICENSE.txt b/packages/wallet/ecpm-permission-module/LICENSE.txt new file mode 100644 index 000000000..15e819500 --- /dev/null +++ b/packages/wallet/ecpm-permission-module/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/wallet/ecpm-permission-module/README.md b/packages/wallet/ecpm-permission-module/README.md new file mode 100644 index 000000000..496025e5b --- /dev/null +++ b/packages/wallet/ecpm-permission-module/README.md @@ -0,0 +1,111 @@ +# ECPM Permission Module + +`@bsv/ecpm-permission-module` implements the BRC-229 `p ecpm` semantic module +for BRC-100 wallets. It applies or removes a BRC-42/43-derived scalar to an +arbitrary validated secp256k1 point without adding a method or wire call to the +fixed BRC-100 interface. + +## Protocol + +Applications call the existing `getPublicKey` method with: + +```text +p ecpm +``` + +The security level remains in the normal BRC-43 tuple. The key ID, +counterparty, privileged selection, privileged reason, and permission behavior +remain in their existing `getPublicKey` fields. + +```ts +const applied = await wallet.getPublicKey({ + protocolID: [2, `p ecpm apply ${pointHex} mental poker deal`], + keyID: 'deck mask', + counterparty: 'self' +}) + +const removed = await wallet.getPublicKey({ + protocolID: [2, `p ecpm remove ${applied.publicKey} mental poker deal`], + keyID: 'deck mask', + counterparty: 'self' +}) +``` + +For both calls, the module derives the scalar under the canonical namespace +`p ecpm mental poker deal`. The operation and point are deliberately excluded +from the BRC-42 invoice so every point uses the same scalar and `remove` +selects the inverse of the scalar used by `apply`. + +## Installation + +Create the module with the wallet's ordinary `KeyDeriverApi` and an +authorization callback, then register it under the `ecpm` scheme: + +```ts +import { createEcpmModule } from '@bsv/ecpm-permission-module' +import { WalletPermissionsManager } from '@bsv/wallet-toolbox-client' + +const ecpm = createEcpmModule({ + keyDeriver: setup.keyDeriver, + authorize: async request => { + return await showTrustedWalletPrompt({ + originator: request.originator, + protocol: request.logicalProtocolID, + counterparty: request.counterparty, + privileged: request.privileged + }) + }, + privilegedKeyDeriver: async reason => { + return await acquirePrivilegedKeyDeriver(reason) + } +}) + +const wallet = new WalletPermissionsManager(setup.wallet, adminOriginator, { + permissionModules: { ecpm } +}) +``` + +Call `ecpm.dispose()` when the host tears down the wallet. The method clears +cached and pending authorization state. + +Security level 0 primary-key requests do not prompt. Levels 1 and 2 require +the authorization callback, with level 2 grants scoped to the counterparty. +Every privileged request requires authorization regardless of security level. +`seekPermission: false` fails unless an applicable grant is already cached. + +## Security model + +The module: + +- accepts only `getPublicKey` in the `p ecpm` namespace, preventing the same + derived key from being reused for signatures, HMACs, or BRC-2 encryption; +- rejects identity-key and `forSelf` requests; +- keeps the point and operation outside the derived-key identity; +- isolates ordinary and privileged derivation providers; +- checks the encoded x coordinate before parsing so a reducing parser cannot + accept a non-canonical point; +- accepts only finite, on-curve, lowercase compressed secp256k1 points; and +- returns the existing `{ publicKey }` result shape, so no BRC-100 wire change + is required. + +The module is trusted wallet code. Applications never receive a key deriver or +private scalar. A privileged provider should acquire protected key material +only after its reason has been displayed and authorized, and should retain it +for no longer than the host wallet's existing privileged-key policy permits. + +## Verification + +```bash +pnpm --filter @bsv/sdk build +pnpm --filter @bsv/wallet-toolbox-client build +pnpm --filter @bsv/ecpm-permission-module typecheck +pnpm --filter @bsv/ecpm-permission-module lint +pnpm --filter @bsv/ecpm-permission-module test:coverage +pnpm --filter @bsv/ecpm-permission-module test:property +pnpm --filter @bsv/ecpm-permission-module build +pnpm --filter @bsv/ecpm-permission-module pack:check +``` + +## License + +Open BSV License version 6. See `LICENSE.txt`. diff --git a/packages/wallet/ecpm-permission-module/browser-budget.json b/packages/wallet/ecpm-permission-module/browser-budget.json new file mode 100644 index 000000000..ac6f0bdee --- /dev/null +++ b/packages/wallet/ecpm-permission-module/browser-budget.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "profile": "browser", + "package": "@bsv/ecpm-permission-module", + "entry": ".", + "requiredExports": ["EcpmPermissionModule", "createEcpmModule"], + "prohibitedExports": [], + "maximumBytes": { + "vite": { "raw": 110000, "gzip": 38000, "brotli": 32000 }, + "esbuild": { "raw": 114000, "gzip": 43000, "brotli": 37000 } + } +} diff --git a/packages/wallet/ecpm-permission-module/jest.config.cjs b/packages/wallet/ecpm-permission-module/jest.config.cjs new file mode 100644 index 000000000..e816f8349 --- /dev/null +++ b/packages/wallet/ecpm-permission-module/jest.config.cjs @@ -0,0 +1,38 @@ +/** @type {import('jest').Config} */ +module.exports = { + bail: 1, + collectCoverageFrom: ['src/**/*.ts', '!src/**/__tests__/**'], + coverageDirectory: 'coverage', + coverageThreshold: { + global: { + branches: 85, + functions: 90, + lines: 90, + statements: 90 + } + }, + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1' + }, + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'node', + testMatch: ['**/__tests__/**/*.test.ts'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + allowSyntheticDefaultImports: true, + esModuleInterop: true, + module: 'ESNext', + moduleResolution: 'bundler', + strict: true, + target: 'ES2022', + types: ['jest', 'node'] + } + } + ] + }, + verbose: true +} diff --git a/packages/wallet/ecpm-permission-module/package.json b/packages/wallet/ecpm-permission-module/package.json new file mode 100644 index 000000000..e2b6253c7 --- /dev/null +++ b/packages/wallet/ecpm-permission-module/package.json @@ -0,0 +1,78 @@ +{ + "name": "@bsv/ecpm-permission-module", + "version": "0.1.0", + "sideEffects": false, + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public" + }, + "description": "BRC-98 ECPM semantic module for elliptic-curve point multiplication in BRC-100 wallets", + "type": "module", + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + } + } + }, + "files": [ + "dist", + "README.md", + "LICENSE.txt" + ], + "scripts": { + "build": "tsdown src/index.ts --format esm --dts --sourcemap --clean --out-dir dist --tsconfig tsconfig.build.json", + "clean": "rm -rf dist", + "format:check": "pnpm --workspace-root exec prettier --check \"packages/wallet/ecpm-permission-module/src/**/*.ts\" \"packages/wallet/ecpm-permission-module/README.md\" \"packages/wallet/ecpm-permission-module/*.{cjs,json,ts}\"", + "lint": "oxlint src --deny-warnings", + "pack:check": "node ../../../scripts/check-package-artifact.mjs . --modes esm --exports EcpmPermissionModule,createEcpmModule", + "test": "jest", + "test:browser": "pnpm build && node ../../../scripts/check-browser-package.mjs .", + "test:coverage": "jest --coverage", + "test:property": "jest --runInBand --runTestsByPath src/__tests__/EcpmPermissionModule.property.test.ts", + "typecheck": "tsc --project tsconfig.typecheck.json" + }, + "keywords": [ + "brc-98", + "brc-229", + "ecpm", + "permissions", + "wallet", + "BSV" + ], + "author": "BSV Blockchain Association", + "license": "SEE LICENSE IN LICENSE.txt", + "peerDependencies": { + "@bsv/sdk": "^2.4.1", + "@bsv/wallet-toolbox-client": "^2.11.0" + }, + "devDependencies": { + "@bsv/sdk": "workspace:^", + "@bsv/wallet-toolbox-client": "workspace:^", + "@jest/globals": "^30.4.1", + "@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" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/bsv-blockchain/ts-stack.git", + "directory": "packages/wallet/ecpm-permission-module" + }, + "bugs": { + "url": "https://github.com/bsv-blockchain/ts-stack/issues" + }, + "homepage": "https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/ecpm-permission-module#readme" +} diff --git a/packages/wallet/ecpm-permission-module/src/EcpmPermissionModule.ts b/packages/wallet/ecpm-permission-module/src/EcpmPermissionModule.ts new file mode 100644 index 000000000..713d1b2d1 --- /dev/null +++ b/packages/wallet/ecpm-permission-module/src/EcpmPermissionModule.ts @@ -0,0 +1,294 @@ +import { + BigNumber, + Curve, + Point, + Utils, + type GetPublicKeyArgs, + type PubKeyHex, + type SecurityLevel +} from '@bsv/sdk' +import type { + PermissionsModule, + PermissionsModuleNext, + PermissionsModuleRequest +} from '@bsv/wallet-toolbox-client' +import type { + EcpmAuthorizationRequest, + EcpmKeyDeriver, + EcpmMultiplyInput, + EcpmPermissionModuleOptions, + ParsedEcpmRequest +} from './types.js' + +const ECPM_PATTERN = /^p ecpm (apply|remove) (0[23][0-9a-f]{64}) ([a-z0-9]+(?: [a-z0-9]+)*)$/ +const DEFAULT_AUTHORIZATION_TTL = 5 * 60 * 1000 +const MAX_AUTHORIZATION_TTL = 24 * 60 * 60 * 1000 + +/** Implements the BRC-229 `p ecpm` semantic permission module. */ +export class EcpmPermissionModule implements PermissionsModule { + private readonly keyDeriver: EcpmKeyDeriver + private readonly privilegedKeyDeriver: EcpmPermissionModuleOptions['privilegedKeyDeriver'] + private readonly authorize: EcpmPermissionModuleOptions['authorize'] + private readonly authorizationTTL: number + private readonly grants = new Map() + private readonly pendingGrants = new Map>() + + constructor(options: EcpmPermissionModuleOptions) { + if (options?.keyDeriver == null || typeof options.keyDeriver.derivePrivateKey !== 'function') { + throw new TypeError('ECPM: keyDeriver with derivePrivateKey is required') + } + const authorizationTTL = options.authorizationTTL ?? DEFAULT_AUTHORIZATION_TTL + if ( + !Number.isSafeInteger(authorizationTTL) || + authorizationTTL <= 0 || + authorizationTTL > MAX_AUTHORIZATION_TTL + ) { + throw new RangeError('ECPM: authorizationTTL must be between 1 ms and 24 hours') + } + this.keyDeriver = options.keyDeriver + this.privilegedKeyDeriver = options.privilegedKeyDeriver + this.authorize = options.authorize + this.authorizationTTL = authorizationTTL + } + + /** Clears cached and pending authorization state. */ + dispose(): void { + this.grants.clear() + this.pendingGrants.clear() + } + + /** Semantic P-module entry point; ECPM never forwards to ordinary `getPublicKey`. */ + async handleRequest( + request: PermissionsModuleRequest, + _next: PermissionsModuleNext + ): Promise<{ publicKey: PubKeyHex }> { + if (request.method !== 'getPublicKey') { + throw new Error(`ECPM: ${request.method} is not permitted in the p ecpm namespace`) + } + if (typeof request.originator !== 'string' || request.originator.length === 0) { + throw new Error('ECPM: originator is required') + } + + const parsed = this.parseRequest(request.args) + await this.ensureAuthorized(parsed, request.originator) + const keyDeriver = await this.selectKeyDeriver(parsed) + const derivedKey = keyDeriver.derivePrivateKey( + parsed.derivationProtocolID, + parsed.keyID, + parsed.counterparty + ) + return { + publicKey: this.multiply({ + point: parsed.point, + derivedKey, + operation: parsed.operation + }) + } + } + + async onRequest(request: PermissionsModuleRequest): Promise<{ args: object }> { + return { args: request.args } + } + + async onResponse(result: unknown): Promise { + return result + } + + private parseRequest(rawArgs: object): ParsedEcpmRequest { + if (rawArgs == null || typeof rawArgs !== 'object' || Array.isArray(rawArgs)) { + throw new TypeError('ECPM: getPublicKey arguments must be an object') + } + const args = rawArgs as GetPublicKeyArgs + if (args.identityKey === true) { + throw new Error('ECPM: identityKey is prohibited') + } + if (args.forSelf === true) { + throw new Error('ECPM: forSelf is not defined for this module') + } + if (!Array.isArray(args.protocolID) || args.protocolID.length !== 2) { + throw new Error('ECPM: protocolID is required') + } + const [securityLevel, protocolName] = args.protocolID + if (!this.isSecurityLevel(securityLevel) || typeof protocolName !== 'string') { + throw new Error('ECPM: invalid protocolID') + } + const match = ECPM_PATTERN.exec(protocolName) + if (match == null) { + throw new Error('ECPM: protocol must be p ecpm ') + } + + const operation = match[1] as 'apply' | 'remove' + const point = match[2] as PubKeyHex + const logicalProtocolID = match[3] + this.validateLogicalProtocol(logicalProtocolID) + this.parseValidPoint(point) + + const keyID = args.keyID + if (typeof keyID !== 'string' || Utils.toArray(keyID, 'utf8').length < 1) { + throw new Error('ECPM: keyID is required') + } + if (Utils.toArray(keyID, 'utf8').length > 800) { + throw new Error('ECPM: keyID exceeds 800 bytes') + } + + const counterparty = (args.counterparty ?? 'self') as PubKeyHex | 'self' | 'anyone' + this.validateCounterparty(counterparty) + const privileged = args.privileged === true + const privilegedReason = args.privilegedReason + if (privileged) this.validatePrivilegedReason(privilegedReason) + + return { + args, + operation, + point, + logicalProtocolID, + derivationProtocolID: [securityLevel, `p ecpm ${logicalProtocolID}`], + keyID, + counterparty, + privileged, + privilegedReason + } + } + + private validateLogicalProtocol(logicalProtocolID: string): void { + const bytes = Utils.toArray(logicalProtocolID, 'utf8').length + if (bytes < 5 || bytes > 273) { + throw new Error('ECPM: logical protocol ID must be between 5 and 273 bytes') + } + if ( + logicalProtocolID.includes(' ') || + logicalProtocolID.endsWith(' protocol') || + !/^[a-z0-9 ]+$/.test(logicalProtocolID) + ) { + throw new Error('ECPM: invalid logical protocol ID') + } + } + + private validateCounterparty(counterparty: PubKeyHex | 'self' | 'anyone'): void { + if (counterparty === 'self' || counterparty === 'anyone') return + if (typeof counterparty !== 'string') { + throw new TypeError('ECPM: counterparty must be self, anyone, or a compressed public key') + } + this.parseValidPoint(counterparty) + } + + private validatePrivilegedReason(reason: string | undefined): void { + if (typeof reason !== 'string') { + throw new TypeError('ECPM: privilegedReason is required for privileged operations') + } + const bytes = Utils.toArray(reason, 'utf8').length + if (bytes < 5 || bytes > 50) { + throw new Error('ECPM: privilegedReason must be between 5 and 50 bytes') + } + } + + private parseValidPoint(pointHex: PubKeyHex): Point { + if (!/^0[23][0-9a-f]{64}$/.test(pointHex)) { + throw new Error('ECPM: expected a lowercase 33-byte compressed secp256k1 point') + } + const curve = new Curve() + if (new BigNumber(pointHex.slice(2), 16).cmp(curve.p) >= 0) { + throw new Error('ECPM: x is not a canonical field element') + } + let point: Point + try { + point = Point.fromString(pointHex) + } catch { + throw new Error('ECPM: point could not be decoded') + } + if (point.isInfinity() || !point.validate()) { + throw new Error('ECPM: point is not a finite secp256k1 point') + } + return point + } + + private async ensureAuthorized(parsed: ParsedEcpmRequest, originator: string): Promise { + const authorization = this.authorizationRequest(parsed, originator) + if (authorization.securityLevel === 0 && !authorization.privileged) return + + const scope = this.authorizationScope(authorization) + const now = Date.now() + const expiry = this.grants.get(scope) + if (expiry != null && expiry > now) return + this.grants.delete(scope) + + if (parsed.args.seekPermission === false) { + throw new Error('ECPM: permission is required and seekPermission is false') + } + if (this.authorize == null) { + throw new Error('ECPM: no authorization handler is configured') + } + + let pending = this.pendingGrants.get(scope) + if (pending == null) { + pending = Promise.resolve(this.authorize(authorization)) + this.pendingGrants.set(scope, pending) + } + let approved: boolean + try { + approved = await pending + } finally { + if (this.pendingGrants.get(scope) === pending) this.pendingGrants.delete(scope) + } + if (approved !== true) throw new Error('ECPM: user denied permission') + this.grants.set(scope, Date.now() + this.authorizationTTL) + } + + private authorizationRequest( + parsed: ParsedEcpmRequest, + originator: string + ): EcpmAuthorizationRequest { + return { + originator, + securityLevel: parsed.derivationProtocolID[0], + logicalProtocolID: parsed.logicalProtocolID, + keyID: parsed.keyID, + counterparty: parsed.counterparty, + privileged: parsed.privileged, + privilegedReason: parsed.privilegedReason, + operation: parsed.operation, + point: parsed.point + } + } + + private authorizationScope(request: EcpmAuthorizationRequest): string { + const counterparty = request.securityLevel === 2 ? request.counterparty : '*' + return [ + request.originator, + request.securityLevel, + request.logicalProtocolID, + counterparty, + request.privileged ? 'privileged' : 'primary' + ].join('\u0000') + } + + private async selectKeyDeriver(parsed: ParsedEcpmRequest): Promise { + if (!parsed.privileged) return this.keyDeriver + if (this.privilegedKeyDeriver == null) { + throw new Error('ECPM: privileged key derivation is unavailable') + } + const deriver = await this.privilegedKeyDeriver(parsed.privilegedReason!) + if (deriver == null || typeof deriver.derivePrivateKey !== 'function') { + throw new Error('ECPM: privileged key provider returned an invalid deriver') + } + return deriver + } + + private multiply(input: EcpmMultiplyInput): PubKeyHex { + const point = this.parseValidPoint(input.point) + const curve = new Curve() + const scalar = + input.operation === 'remove' + ? input.derivedKey.invm(curve.n) + : new BigNumber(input.derivedKey.toHex(), 16) + const result = point.mul(scalar) + if (result.isInfinity()) { + throw new Error('ECPM: result is the point at infinity') + } + return result.encode(true, 'hex') as PubKeyHex + } + + private isSecurityLevel(value: unknown): value is SecurityLevel { + return value === 0 || value === 1 || value === 2 + } +} diff --git a/packages/wallet/ecpm-permission-module/src/__tests__/EcpmPermissionModule.property.test.ts b/packages/wallet/ecpm-permission-module/src/__tests__/EcpmPermissionModule.property.test.ts new file mode 100644 index 000000000..a5b3a31a6 --- /dev/null +++ b/packages/wallet/ecpm-permission-module/src/__tests__/EcpmPermissionModule.property.test.ts @@ -0,0 +1,53 @@ +import fc from 'fast-check' +import { BigNumber, CachedKeyDeriver, Curve, PrivateKey, type PubKeyHex } from '@bsv/sdk' +import { EcpmPermissionModule } from '../EcpmPermissionModule.js' + +const curve = new Curve() +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 } : {}) +}) + +describe('EcpmPermissionModule properties', () => { + it('round-trips every generated non-zero root and point scalar', async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 1, max: 1_000_000 }), + fc.integer({ min: 1, max: 1_000_000 }), + async (rootScalar, pointScalar) => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(rootScalar)) + }) + const original = curve.g.mul(new BigNumber(pointScalar)).encode(true, 'hex') as PubKeyHex + const call = async (operation: 'apply' | 'remove', point: PubKeyHex) => + await module.handleRequest!( + { + method: 'getPublicKey', + originator: 'property.example', + args: { + protocolID: [0, `p ecpm ${operation} ${point} property poker game`], + keyID: 'property key', + counterparty: 'self' + } + }, + async () => { + throw new Error('unexpected underlying call') + } + ) + + const applied = await call('apply', original) + const removed = await call('remove', applied.publicKey) + expect(removed.publicKey).toBe(original) + } + ) + ) + }) +}) diff --git a/packages/wallet/ecpm-permission-module/src/__tests__/EcpmPermissionModule.test.ts b/packages/wallet/ecpm-permission-module/src/__tests__/EcpmPermissionModule.test.ts new file mode 100644 index 000000000..bbba0bc66 --- /dev/null +++ b/packages/wallet/ecpm-permission-module/src/__tests__/EcpmPermissionModule.test.ts @@ -0,0 +1,484 @@ +import { + BigNumber, + CachedKeyDeriver, + Curve, + PrivateKey, + type GetPublicKeyArgs, + type PubKeyHex +} from '@bsv/sdk' +import { jest } from '@jest/globals' +import { EcpmPermissionModule } from '../EcpmPermissionModule.js' +import { createEcpmModule } from '../index.js' + +const curve = new Curve() +const point = (scalar: number): PubKeyHex => + curve.g.mul(new BigNumber(scalar)).encode(true, 'hex') as PubKeyHex + +const requestArgs = ( + input: PubKeyHex, + operation: 'apply' | 'remove' = 'apply', + overrides: Partial = {} +): GetPublicKeyArgs => ({ + protocolID: [0, `p ecpm ${operation} ${input} mental poker deal`], + keyID: 'deck mask', + counterparty: 'self', + ...overrides +}) + +const execute = async ( + module: EcpmPermissionModule, + args: GetPublicKeyArgs, + method = 'getPublicKey', + originator = 'poker.example' +): Promise<{ publicKey: PubKeyHex }> => + await module.handleRequest!({ method, args, originator }, async () => { + throw new Error('underlying wallet must not be called') + }) + +describe('EcpmPermissionModule', () => { + it('applies and removes the same derived scalar without exposing it', async () => { + const keyDeriver = new CachedKeyDeriver(new PrivateKey(11)) + const module = new EcpmPermissionModule({ keyDeriver }) + const original = point(7) + + const applied = await execute(module, requestArgs(original)) + const removed = await execute(module, requestArgs(applied.publicKey, 'remove')) + + expect(applied.publicKey).not.toBe(original) + expect(removed.publicKey).toBe(original) + }) + + it('exports a factory and harmless legacy transformation hooks', async () => { + const module = createEcpmModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(12)) + }) + const request = { + method: 'getPublicKey', + args: requestArgs(point(3)), + originator: 'poker.example' + } + + await expect(module.onRequest(request)).resolves.toEqual({ args: request.args }) + await expect(module.onResponse({ publicKey: point(4) })).resolves.toEqual({ + publicKey: point(4) + }) + }) + + it('agrees with direct multiplication by the canonical module-derived key', async () => { + const keyDeriver = new CachedKeyDeriver(new PrivateKey(19)) + const module = new EcpmPermissionModule({ keyDeriver }) + const original = point(13) + const derived = keyDeriver.derivePrivateKey( + [0, 'p ecpm mental poker deal'], + 'deck mask', + 'self' + ) + const expected = curve.g + .mul(new BigNumber(13)) + .mul(new BigNumber(derived.toHex(), 16)) + .encode(true, 'hex') + + await expect(execute(module, requestArgs(original))).resolves.toEqual({ + publicKey: expected + }) + }) + + it('accepts the inclusive protocol, key, reason, TTL, and counterparty boundaries', async () => { + const privileged = new CachedKeyDeriver(new PrivateKey(21)) + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(20)), + authorizationTTL: 24 * 60 * 60 * 1000, + authorize: async () => true, + privilegedKeyDeriver: async () => privileged + }) + const original = point(14) + + await expect( + execute( + module, + requestArgs(original, 'apply', { + protocolID: [0, `p ecpm apply ${original} abcde`], + keyID: 'x', + counterparty: 'anyone' + }) + ) + ).resolves.toHaveProperty('publicKey') + await expect( + execute( + module, + requestArgs(original, 'apply', { + protocolID: [0, `p ecpm apply ${original} ${'a'.repeat(273)}`], + keyID: 'x'.repeat(800) + }) + ) + ).resolves.toHaveProperty('publicKey') + for (const privilegedReason of ['abcde', 'x'.repeat(50)]) { + await expect( + execute( + module, + requestArgs(original, 'apply', { + privileged: true, + privilegedReason + }) + ) + ).resolves.toHaveProperty('publicKey') + } + }) + + it('commutes across independent wallet modules', async () => { + const alice = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(23)) + }) + const bob = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(29)) + }) + const original = point(5) + + const ab = await execute( + bob, + requestArgs((await execute(alice, requestArgs(original))).publicKey) + ) + const ba = await execute( + alice, + requestArgs((await execute(bob, requestArgs(original))).publicKey) + ) + + expect(ab).toEqual(ba) + }) + + it('excludes the operation and input point from the derivation identity', async () => { + const keyDeriver = new CachedKeyDeriver(new PrivateKey(31)) + const derivePrivateKey = jest.spyOn(keyDeriver, 'derivePrivateKey') + const module = new EcpmPermissionModule({ keyDeriver }) + + const first = await execute(module, requestArgs(point(2))) + await execute(module, requestArgs(first.publicKey, 'remove')) + + expect(derivePrivateKey).toHaveBeenNthCalledWith( + 1, + [0, 'p ecpm mental poker deal'], + 'deck mask', + 'self' + ) + expect(derivePrivateKey).toHaveBeenNthCalledWith( + 2, + [0, 'p ecpm mental poker deal'], + 'deck mask', + 'self' + ) + }) + + it('separates scalars by logical protocol, key ID, and counterparty', async () => { + const keyDeriver = new CachedKeyDeriver(new PrivateKey(37)) + const module = new EcpmPermissionModule({ keyDeriver }) + const original = point(17) + const counterparty = new PrivateKey(41).toPublicKey().toString() + + const results = await Promise.all([ + execute(module, requestArgs(original)), + execute( + module, + requestArgs(original, 'apply', { + protocolID: [0, `p ecpm apply ${original} another poker game`] + }) + ), + execute(module, requestArgs(original, 'apply', { keyID: 'another key' })), + execute(module, requestArgs(original, 'apply', { counterparty })) + ]) + + expect(new Set(results.map(result => result.publicKey)).size).toBe(4) + }) + + it('authorizes level 1 once per application and protocol, including concurrent calls', async () => { + let resolveAuthorization!: (approved: boolean) => void + const authorization = new Promise(resolve => { + resolveAuthorization = resolve + }) + const authorize = jest.fn(async () => await authorization) + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(43)), + authorize + }) + const args = requestArgs(point(3), 'apply', { + protocolID: [1, `p ecpm apply ${point(3)} mental poker deal`] + }) + + const first = execute(module, args) + const second = execute(module, args) + resolveAuthorization(true) + await Promise.all([first, second]) + await execute( + module, + requestArgs(point(4), 'apply', { + protocolID: [1, `p ecpm apply ${point(4)} mental poker deal`], + keyID: 'another key' + }) + ) + + expect(authorize).toHaveBeenCalledTimes(1) + }) + + it('scopes level 2 grants by counterparty and clears them on dispose', async () => { + const authorize = jest.fn(async () => true) + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(47)), + authorize + }) + const firstCounterparty = new PrivateKey(53).toPublicKey().toString() + const secondCounterparty = new PrivateKey(59).toPublicKey().toString() + const levelTwo = (counterparty: string): GetPublicKeyArgs => + requestArgs(point(6), 'apply', { + protocolID: [2, `p ecpm apply ${point(6)} mental poker deal`], + counterparty + }) + + await execute(module, levelTwo(firstCounterparty)) + await execute(module, levelTwo(firstCounterparty)) + await execute(module, levelTwo(secondCounterparty)) + module.dispose() + await execute(module, levelTwo(firstCounterparty)) + + expect(authorize).toHaveBeenCalledTimes(3) + }) + + it('honors seekPermission false and denial without calling the key deriver', async () => { + const keyDeriver = new CachedKeyDeriver(new PrivateKey(61)) + const derivePrivateKey = jest.spyOn(keyDeriver, 'derivePrivateKey') + const authorize = jest.fn(async () => false) + const module = new EcpmPermissionModule({ keyDeriver, authorize }) + const levelOne = requestArgs(point(8), 'apply', { + protocolID: [1, `p ecpm apply ${point(8)} mental poker deal`] + }) + + await expect(execute(module, { ...levelOne, seekPermission: false })).rejects.toThrow( + /seekPermission/ + ) + await expect(execute(module, levelOne)).rejects.toThrow(/denied/) + expect(authorize).toHaveBeenCalledTimes(1) + expect(derivePrivateKey).not.toHaveBeenCalled() + }) + + it('uses the privileged deriver and forwards the existing privileged reason', async () => { + const regular = new CachedKeyDeriver(new PrivateKey(67)) + const privileged = new CachedKeyDeriver(new PrivateKey(71)) + const authorize = jest.fn(async () => true) + const privilegedKeyDeriver = jest.fn(async () => privileged) + const module = new EcpmPermissionModule({ + keyDeriver: regular, + privilegedKeyDeriver, + authorize + }) + const original = point(9) + + const regularResult = await execute(module, requestArgs(original)) + const privilegedResult = await execute( + module, + requestArgs(original, 'apply', { + privileged: true, + privilegedReason: 'Protect the private card mask' + }) + ) + + expect(privilegedResult).not.toEqual(regularResult) + expect(privilegedKeyDeriver).toHaveBeenCalledWith('Protect the private card mask') + expect(authorize).toHaveBeenCalledWith( + expect.objectContaining({ + privileged: true, + privilegedReason: 'Protect the private card mask' + }) + ) + }) + + it.each<[string, RegExp]>([ + ['createSignature', /not permitted/], + ['encrypt', /not permitted/] + ])('rejects unrelated method %s in the ECPM key namespace', async (method, error) => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(73)) + }) + await expect(execute(module, requestArgs(point(2)), method)).rejects.toThrow(error) + }) + + it.each<[Partial, RegExp]>([ + [{ identityKey: true }, /identityKey/], + [{ forSelf: true }, /forSelf/], + [{ keyID: '' }, /keyID/], + [{ keyID: 'x'.repeat(801) }, /800 bytes/], + [{ privileged: true }, /privilegedReason/], + [{ privileged: true, privilegedReason: 'bad' }, /between 5 and 50/] + ])('rejects conflicting or invalid existing getPublicKey arguments', async (override, error) => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(79)) + }) + await expect(execute(module, requestArgs(point(2), 'apply', override))).rejects.toThrow(error) + }) + + it.each([ + 'p ecpm apply missing', + `p ecpm change ${point(2)} mental poker deal`, + `p ecpm apply ${point(2).toUpperCase()} mental poker deal`, + `p ecpm apply ${point(2)} Bad Protocol`, + `p ecpm apply ${point(2)} bad spacing`, + `p ecpm apply ${point(2)} poker protocol`, + `p ecpm apply ${point(2)} abcd`, + `p ecpm apply ${point(2)} ${'a'.repeat(274)}` + ])('rejects malformed module protocol %s', async protocolName => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(83)) + }) + await expect( + execute(module, requestArgs(point(2), 'apply', { protocolID: [0, protocolName] })) + ).rejects.toThrow() + }) + + it('rejects the non-canonical x coordinate before the SDK parser can reduce it', async () => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(89)) + }) + const nonCanonical = `02${'ff'.repeat(32)}` + await expect(execute(module, requestArgs(nonCanonical, 'apply'))).rejects.toThrow( + /canonical field element/ + ) + }) + + it('rejects compressed encodings that do not identify a curve point', async () => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(91)) + }) + const undecodable = `02${'00'.repeat(32)}` + await expect(execute(module, requestArgs(undecodable, 'apply'))).rejects.toThrow( + 'ECPM: point could not be decoded' + ) + }) + + it('rejects a malformed counterparty before key derivation', async () => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(92)) + }) + await expect( + execute(module, requestArgs(point(2), 'apply', { counterparty: 'not a key' })) + ).rejects.toThrow('ECPM: expected a lowercase 33-byte compressed secp256k1 point') + }) + + it('rejects non-string originators and distinguishes malformed request fields', async () => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(93)) + }) + await expect( + module.handleRequest( + { method: 'getPublicKey', args: requestArgs(point(2)), originator: 42 as never }, + async () => undefined + ) + ).rejects.toThrow('ECPM: originator is required') + await expect( + module.handleRequest( + { method: 'getPublicKey', args: 'invalid' as never, originator: 'poker.example' }, + async () => undefined + ) + ).rejects.toThrow('ECPM: getPublicKey arguments must be an object') + await expect( + execute( + module, + requestArgs(point(2), 'apply', { + protocolID: [0, 'valid protocol', 'extra'] as never + }) + ) + ).rejects.toThrow('ECPM: protocolID is required') + await expect( + execute(module, requestArgs(point(2), 'apply', { protocolID: [0, 42 as never] })) + ).rejects.toThrow('ECPM: invalid protocolID') + }) + + it('rejects the field-prime x boundary and uppercase counterparties', async () => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(94)) + }) + const fieldPrime = curve.p.toString(16).padStart(64, '0') + await expect( + execute(module, requestArgs(`02${fieldPrime}` as PubKeyHex, 'apply')) + ).rejects.toThrow('ECPM: x is not a canonical field element') + await expect( + execute(module, requestArgs(point(2), 'apply', { counterparty: point(3).toUpperCase() })) + ).rejects.toThrow('ECPM: expected a lowercase 33-byte compressed secp256k1 point') + }) + + it('fails closed when authorization or privileged derivation is unavailable', async () => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(97)) + }) + const levelOne = requestArgs(point(2), 'apply', { + protocolID: [1, `p ecpm apply ${point(2)} mental poker deal`] + }) + + await expect(execute(module, levelOne)).rejects.toThrow(/authorization handler/) + await expect( + execute( + module, + requestArgs(point(2), 'apply', { + privileged: true, + privilegedReason: 'Use protected poker key' + }) + ) + ).rejects.toThrow(/authorization handler/) + }) + + it('fails closed when a privileged key provider is missing or invalid', async () => { + const ordinary = new CachedKeyDeriver(new PrivateKey(98)) + const args = requestArgs(point(2), 'apply', { + privileged: true, + privilegedReason: 'Use protected poker key' + }) + const withoutProvider = new EcpmPermissionModule({ + keyDeriver: ordinary, + authorize: async () => true + }) + const invalidProvider = new EcpmPermissionModule({ + keyDeriver: ordinary, + authorize: async () => true, + privilegedKeyDeriver: async () => null as never + }) + + await expect(execute(withoutProvider, args)).rejects.toThrow(/unavailable/) + await expect(execute(invalidProvider, args)).rejects.toThrow(/invalid deriver/) + }) + + it.each([ + [null, /arguments must be an object/], + [[], /arguments must be an object/], + [{ keyID: 'key' }, /protocolID is required/], + [{ protocolID: [3, 'p ecpm apply invalid value'], keyID: 'key' }, /invalid protocolID/] + ])('rejects malformed argument structure %#', async (args, error) => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(99)) + }) + await expect( + module.handleRequest( + { method: 'getPublicKey', args: args as never, originator: 'poker.example' }, + async () => undefined + ) + ).rejects.toThrow(error) + }) + + it('rejects an absent originator and an invalid counterparty type', async () => { + const module = new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(100)) + }) + await expect(execute(module, requestArgs(point(2)), 'getPublicKey', '')).rejects.toThrow( + /originator/ + ) + await expect( + execute(module, requestArgs(point(2), 'apply', { counterparty: 42 as never })) + ).rejects.toThrow(/counterparty/) + }) + + it('validates constructor options', () => { + expect(() => new EcpmPermissionModule({ keyDeriver: {} as never })).toThrow(/keyDeriver/) + expect( + () => + new EcpmPermissionModule({ + keyDeriver: new CachedKeyDeriver(new PrivateKey(101)), + authorizationTTL: 0 + }) + ).toThrow(/authorizationTTL/) + }) +}) diff --git a/packages/wallet/ecpm-permission-module/src/index.ts b/packages/wallet/ecpm-permission-module/src/index.ts new file mode 100644 index 000000000..2fe0b05eb --- /dev/null +++ b/packages/wallet/ecpm-permission-module/src/index.ts @@ -0,0 +1,15 @@ +import { EcpmPermissionModule } from './EcpmPermissionModule.js' +import type { EcpmPermissionModuleOptions } from './types.js' + +export { EcpmPermissionModule } from './EcpmPermissionModule.js' +export type { + EcpmAuthorizationHandler, + EcpmAuthorizationRequest, + EcpmKeyDeriver, + EcpmOperation, + EcpmPermissionModuleOptions, + EcpmPrivilegedKeyDeriver +} from './types.js' + +export const createEcpmModule = (options: EcpmPermissionModuleOptions): EcpmPermissionModule => + new EcpmPermissionModule(options) diff --git a/packages/wallet/ecpm-permission-module/src/types.ts b/packages/wallet/ecpm-permission-module/src/types.ts new file mode 100644 index 000000000..c31cae232 --- /dev/null +++ b/packages/wallet/ecpm-permission-module/src/types.ts @@ -0,0 +1,59 @@ +import type { + GetPublicKeyArgs, + KeyDeriverApi, + PrivateKey, + PubKeyHex, + SecurityLevel, + WalletProtocol +} from '@bsv/sdk' + +export type EcpmOperation = 'apply' | 'remove' + +export type EcpmKeyDeriver = Pick + +export type EcpmPrivilegedKeyDeriver = (reason: string) => EcpmKeyDeriver | Promise + +export interface EcpmAuthorizationRequest { + originator: string + securityLevel: SecurityLevel + logicalProtocolID: string + keyID: string + counterparty: PubKeyHex | 'self' | 'anyone' + privileged: boolean + privilegedReason?: string + operation: EcpmOperation + point: PubKeyHex +} + +export type EcpmAuthorizationHandler = ( + request: EcpmAuthorizationRequest +) => boolean | Promise + +export interface EcpmPermissionModuleOptions { + /** Derives ordinary BRC-42/43 keys for this wallet. */ + keyDeriver: EcpmKeyDeriver + /** Retrieves a privileged deriver only after the supplied reason is authorized. */ + privilegedKeyDeriver?: EcpmPrivilegedKeyDeriver + /** Required for security levels 1/2 and every privileged request. */ + authorize?: EcpmAuthorizationHandler + /** Duration of a successful protocol grant. Defaults to five minutes. */ + authorizationTTL?: number +} + +export interface ParsedEcpmRequest { + args: GetPublicKeyArgs + operation: EcpmOperation + point: PubKeyHex + logicalProtocolID: string + derivationProtocolID: WalletProtocol + keyID: string + counterparty: PubKeyHex | 'self' | 'anyone' + privileged: boolean + privilegedReason?: string +} + +export interface EcpmMultiplyInput { + point: PubKeyHex + derivedKey: PrivateKey + operation: EcpmOperation +} diff --git a/packages/wallet/ecpm-permission-module/tsconfig.build.json b/packages/wallet/ecpm-permission-module/tsconfig.build.json new file mode 100644 index 000000000..7a930fe26 --- /dev/null +++ b/packages/wallet/ecpm-permission-module/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "incremental": false, + "noEmit": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/__tests__/**"] +} diff --git a/packages/wallet/ecpm-permission-module/tsconfig.json b/packages/wallet/ecpm-permission-module/tsconfig.json new file mode 100644 index 000000000..67abe61e6 --- /dev/null +++ b/packages/wallet/ecpm-permission-module/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../config/typescript/dual-runtime.json", + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "strict": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/wallet/ecpm-permission-module/tsconfig.typecheck.json b/packages/wallet/ecpm-permission-module/tsconfig.typecheck.json new file mode 100644 index 000000000..7c4072bd3 --- /dev/null +++ b/packages/wallet/ecpm-permission-module/tsconfig.typecheck.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "incremental": false, + "noEmit": true, + "types": ["jest", "node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/wallet/wallet-toolbox/CHANGELOG.md b/packages/wallet/wallet-toolbox/CHANGELOG.md index c1416b71c..29f1ac349 100644 --- a/packages/wallet/wallet-toolbox/CHANGELOG.md +++ b/packages/wallet/wallet-toolbox/CHANGELOG.md @@ -6,6 +6,13 @@ attention to changes that materially alter behavior or extend functionality. ## wallet-toolbox (unreleased) +- Extend the BRC-98/99/111 permission-module interface with an optional semantic + `handleRequest` hook. A module can now return a conforming BRC-100 result + directly or invoke the underlying wallet operation at most once, while + existing `onRequest`/`onResponse` transformation modules remain compatible. + The companion `@bsv/ecpm-permission-module` uses this hook to implement + `p ecpm` point multiplication without adding a BRC-100 method or wire call. + - Serialize typed AtomicBEEF and competing BEEF in wallet review errors as portable JSON arrays, keeping HTTP and relay error recovery compatible with both historical array wallets and current binary Wallet Wire wallets. diff --git a/packages/wallet/wallet-toolbox/README.md b/packages/wallet/wallet-toolbox/README.md index 62ce42971..84c92fe48 100644 --- a/packages/wallet/wallet-toolbox/README.md +++ b/packages/wallet/wallet-toolbox/README.md @@ -31,6 +31,15 @@ broadcast, so permission approval does not inherit network-broadcast latency. The funding planner prefers settled change and uses queued permission ancestry only as a last resort, keeping the application path fast without hiding funds. +Permission modules may transform calls with `onRequest` and `onResponse`, or +own a P-scheme's semantics with the optional `handleRequest(request, next)` +hook. A semantic handler can return the normal BRC-100 result directly; if it +needs the underlying wallet operation, `next` is guarded so it can be invoked +at most once. Existing transformation-only modules remain compatible. The +standalone `@bsv/ecpm-permission-module` demonstrates this extension by +implementing `p ecpm` point multiplication through `getPublicKey`, without a +new BRC-100 method or wire message. + Immediate actions prefer completed, then unproven, then sending change. A pathological settled plan is compared with pending alternatives by exact serialized BEEF plus transaction bytes; queued ancestry is used only when it is diff --git a/packages/wallet/wallet-toolbox/client/package.json b/packages/wallet/wallet-toolbox/client/package.json index e4463788f..1ca49d415 100644 --- a/packages/wallet/wallet-toolbox/client/package.json +++ b/packages/wallet/wallet-toolbox/client/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-toolbox-client", - "version": "2.10.2", + "version": "2.11.0", "type": "module", "sideEffects": false, "engines": { diff --git a/packages/wallet/wallet-toolbox/mobile/package.json b/packages/wallet/wallet-toolbox/mobile/package.json index 08d60cc2e..eeb4fab1a 100644 --- a/packages/wallet/wallet-toolbox/mobile/package.json +++ b/packages/wallet/wallet-toolbox/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-toolbox-mobile", - "version": "2.10.2", + "version": "2.11.0", "type": "module", "sideEffects": false, "engines": { diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index 42e564efe..79ec07ffc 100644 --- a/packages/wallet/wallet-toolbox/package.json +++ b/packages/wallet/wallet-toolbox/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-toolbox", - "version": "2.10.2", + "version": "2.11.0", "sideEffects": false, "type": "commonjs", "engines": { diff --git a/packages/wallet/wallet-toolbox/src/WalletPermissionsManager.ts b/packages/wallet/wallet-toolbox/src/WalletPermissionsManager.ts index 311ebdeb0..75cf9a590 100644 --- a/packages/wallet/wallet-toolbox/src/WalletPermissionsManager.ts +++ b/packages/wallet/wallet-toolbox/src/WalletPermissionsManager.ts @@ -67,11 +67,35 @@ export type LineItemType = 'input' | 'output' | 'fee' /** Security level for DPACP protocol permissions. */ export type SecurityLevel = 0 | 1 | 2 +/** A wallet request routed to a BRC-98/99/111 permission module. */ +export interface PermissionsModuleRequest { + method: string + args: object + originator: string +} + +/** + * Invokes the underlying BRC-100 method with module-transformed arguments. + * A semantic module can omit this call and return its own conforming result. + */ +export type PermissionsModuleNext = (args: object) => Promise + /** * A permissions module handles request/response transformation for a specific P-protocol or P-basket scheme under BRC-98/99. * Modules are registered in the config mapped by their scheme ID. */ export interface PermissionsModule { + /** + * Optionally owns the complete execution of a P-module request. + * + * This is the semantic-extension hook for schemes whose behavior cannot be + * expressed by argument and response transformation alone. The handler may + * call `next` once to invoke the underlying BRC-100 method, or return a result + * directly. When present, `onRequest` and `onResponse` are not invoked for the + * delegated request. + */ + handleRequest?: (req: PermissionsModuleRequest, next: PermissionsModuleNext) => Promise + /** * Transforms the request before it's passed to the underlying wallet. * Can check and enforce permissions, throw errors, or modify any arguments as needed prior to invocation. @@ -79,7 +103,7 @@ export interface PermissionsModule { * @param req - The incoming request with method, args, and originator * @returns Transformed arguments that will be passed to the underlying wallet */ - onRequest: (req: { method: string; args: object; originator: string }) => Promise<{ args: object }> + onRequest: (req: PermissionsModuleRequest) => Promise<{ args: object }> /** * Transforms the response from the underlying wallet before returning to caller. @@ -657,12 +681,20 @@ export class WalletPermissionsManager implements WalletInterface { throw new Error(`Unsupported P-module scheme: p ${schemeID}`) } + const request = { method, args, originator } + if (module.handleRequest != null) { + let nextCalled = false + return (await module.handleRequest(request, async transformedArgs => { + if (nextCalled) { + throw new Error(`P-module p ${schemeID} called its underlying wallet operation more than once`) + } + nextCalled = true + return await underlyingCall(transformedArgs, originator) + })) as T + } + // Transform request with module - const transformedReq = await module.onRequest({ - method, - args, - originator - }) + const transformedReq = await module.onRequest(request) // Call underlying method with transformed request const results = await underlyingCall(transformedReq.args, originator) diff --git a/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.pmodules.test.ts b/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.pmodules.test.ts index d583c776b..72fc23f87 100644 --- a/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.pmodules.test.ts +++ b/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.pmodules.test.ts @@ -947,4 +947,93 @@ describe('WalletPermissionsManager - Permission Module Support', () => { expect(testModule.onRequest).not.toHaveBeenCalled() }) }) + + describe('P-Module Semantic Handlers', () => { + it('allows a module to return a wallet result without invoking the underlying method', async () => { + const testModule: PermissionsModule = { + handleRequest: jest.fn(async req => { + expect(req.method).toBe('getPublicKey') + expect(req.originator).toBe('app.com') + return { publicKey: `02${'11'.repeat(32)}` } + }), + onRequest: jest.fn(async req => ({ args: req.args })), + onResponse: jest.fn(async result => result) + } + const manager = new WalletPermissionsManager(underlying, 'admin.com', { + permissionModules: { semantic: testModule } + }) + + const result = await manager.getPublicKey( + { + protocolID: [2, `p semantic apply 02${'22'.repeat(32)} example protocol`], + keyID: 'key 1' + }, + 'app.com' + ) + + expect(result).toEqual({ publicKey: `02${'11'.repeat(32)}` }) + expect(underlying.getPublicKey).not.toHaveBeenCalled() + expect(testModule.onRequest).not.toHaveBeenCalled() + expect(testModule.onResponse).not.toHaveBeenCalled() + }) + + it('allows a semantic handler to forward transformed arguments through next', async () => { + const transformedProtocol: [2, string] = [2, 'ordinary protocol'] + const testModule: PermissionsModule = { + handleRequest: jest.fn(async (req, next) => { + const result = await next({ + ...req.args, + protocolID: transformedProtocol + }) + return { ...(result as object), handled: true } + }), + onRequest: jest.fn(async req => ({ args: req.args })), + onResponse: jest.fn(async result => result) + } + const manager = new WalletPermissionsManager(underlying, 'admin.com', { + permissionModules: { semantic: testModule } + }) + underlying.getPublicKey.mockResolvedValue({ publicKey: `03${'33'.repeat(32)}` }) + + const result = await manager.getPublicKey( + { + protocolID: [2, 'p semantic forwarded request'], + keyID: 'key 1' + }, + 'app.com' + ) + + expect(underlying.getPublicKey).toHaveBeenCalledWith( + expect.objectContaining({ protocolID: transformedProtocol }), + 'app.com' + ) + expect(result).toEqual({ publicKey: `03${'33'.repeat(32)}`, handled: true }) + }) + + it('rejects a semantic handler that invokes the underlying operation twice', async () => { + const testModule: PermissionsModule = { + handleRequest: async (request, next) => { + await next(request.args) + return await next(request.args) + }, + onRequest: jest.fn(async request => ({ args: request.args })), + onResponse: jest.fn(async result => result) + } + const manager = new WalletPermissionsManager(underlying, 'admin.com', { + permissionModules: { semantic: testModule } + }) + underlying.getPublicKey.mockResolvedValue({ publicKey: `03${'44'.repeat(32)}` }) + + await expect( + manager.getPublicKey( + { + protocolID: [0, 'p semantic example request'], + keyID: '1' + }, + 'app.com' + ) + ).rejects.toThrow(/more than once/) + expect(underlying.getPublicKey).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5faa6b71a..3c02bb67f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1489,6 +1489,45 @@ importers: specifier: npm:@typescript/typescript6@6.0.2 version: '@typescript/typescript6@6.0.2' + packages/wallet/ecpm-permission-module: + devDependencies: + '@bsv/sdk': + specifier: workspace:^ + version: link:../../sdk + '@bsv/wallet-toolbox-client': + specifier: workspace:^ + version: link:../wallet-toolbox/client + '@jest/globals': + specifier: ^30.4.1 + version: 30.4.1 + '@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/wallet/ts-wallet-relay: dependencies: ws: diff --git a/scripts/contributor-policy.test.mjs b/scripts/contributor-policy.test.mjs index b76736fbf..fa55f2473 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, 44) + assert.equal(result.summary.scopedProjectsAndServices, 45) assert.equal(result.summary.consolidatedLegacyAgentFiles, 31) assert.equal(result.summary.historicalGitHubFiles, 49) assert.equal(result.summary.retiredPackageContributionFiles, 8) diff --git a/scripts/package-documentation.mjs b/scripts/package-documentation.mjs index 85e40a042..52726e8bd 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 31 public manifests, package documentation, and +This page is generated from all ${packages.length} 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 51fe38d7e..6f218a48f 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, 31) - assert.equal(model.packages.filter(pkg => pkg.releaseType !== 'none').length, 31) + assert.equal(model.packages.length, 32) + assert.equal(model.packages.filter(pkg => pkg.releaseType !== 'none').length, 32) 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 2fe19ca4f..76f31f46a 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, 47) + assert.equal(discoverPackageManifests().length, 48) assert.deepEqual(validatePackageLicenses(), []) }) diff --git a/scripts/repository-health.test.mjs b/scripts/repository-health.test.mjs index f1a9593c2..1eeca6a57 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 38-project registry', () => { +test('workspace discovery exactly matches the 39-project registry', () => { const discovered = discoverWorkspaceProjects() - assert.equal(discovered.length, 38) - assert.equal(discovered.filter(project => project.manifest.private !== true).length, 31) + assert.equal(discovered.length, 39) + assert.equal(discovered.filter(project => project.manifest.private !== true).length, 32) 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-08-09' }) assert.deepEqual(result.errors, []) - assert.equal(result.projects.length, 38) - assert.equal(result.publicPackages, 31) + assert.equal(result.projects.length, 39) + assert.equal(result.publicPackages, 32) assert.equal(result.findings.length, 0) }) @@ -213,7 +213,7 @@ test('every public package declares supported runtime and canonical support meta project => project.manifest.private !== true ) - assert.equal(publicPackages.length, 31) + assert.equal(publicPackages.length, 32) for (const project of publicPackages) { assert.equal( project.manifest.engines?.node, @@ -266,7 +266,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, 31) + assert.equal(publicProjects.length, 32) 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 df9027b3a..1db40c06a 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, 30) - assert.equal(result.summary.propertyPackages, 28) + assert.equal(result.summary.propertySuites, 31) + assert.equal(result.summary.propertyPackages, 29) assert.equal(result.summary.propertyExcludedPackages, 6) - assert.equal(result.summary.propertyClassifiedPackages, 34) - assert.equal(result.summary.mutationTargets, 30) + assert.equal(result.summary.propertyClassifiedPackages, 35) + assert.equal(result.summary.mutationTargets, 31) 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 8b8885dd0..52998dc11 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, 44) + assert.equal(report.governed, 45) assert.equal(report.codegen, 1) assert.ok(report.configurations > 100) assert.equal(report.profiles, 9)