From 6631ff551ea99ce231e4437ed652052ade2e000d Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sat, 26 Sep 2026 21:51:16 -0700 Subject: [PATCH 1/2] Fix authenticated HTTP binary payload capacity --- docs/packages/sdk/bsv-sdk.md | 6 +- docs/reference/package-api-migrations.md | 6 +- docs/reference/stack-facts.md | 2 +- governance/package-release-notes.json | 6 +- governance/repository-health/baselines.json | 2 +- .../chirp/test/uploader.authenticated.test.ts | 162 +++++++++--------- packages/sdk/CHANGELOG.md | 4 + packages/sdk/README.md | 7 + packages/sdk/package.json | 2 +- packages/sdk/src/auth/clients/AuthFetch.ts | 5 +- .../clients/__tests__/AuthFetch.peer.test.ts | 6 +- .../SimplifiedFetchTransport.test.ts | 13 ++ 12 files changed, 128 insertions(+), 93 deletions(-) diff --git a/docs/packages/sdk/bsv-sdk.md b/docs/packages/sdk/bsv-sdk.md index dc7303154..4697cc827 100644 --- a/docs/packages/sdk/bsv-sdk.md +++ b/docs/packages/sdk/bsv-sdk.md @@ -3,10 +3,10 @@ id: bsv-sdk title: '@bsv/sdk' kind: package domain: sdk -version: '2.8.8' +version: '2.8.9' npm: '@bsv/sdk' -last_updated: '2026-09-25' -last_verified: '2026-09-25' +last_updated: '2026-09-27' +last_verified: '2026-09-27' review_cadence_days: 30 status: stable tags: ['sdk', 'crypto', 'transactions'] diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 6f02e2f16..7fda55bc7 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -48,7 +48,7 @@ and clean-consumer tests remain the executable type authority. | `@bsv/overlay-topics` | `1.9.1` | `1.9.1` | none | [API and usage](../packages/overlays/overlay-topics.md) | No wire, API, or database migration. Deploy the reviewed patch through the protected release workflow; retain existing validation and configured resource bounds. | | `@bsv/paymail` | `2.4.9` | `2.4.10` | patch | [API and usage](../packages/messaging/paymail.md) | None. ESM consumers are unaffected. | | `@bsv/payment-express-middleware` | `2.1.8` | `2.1.8` | none | [API and usage](../packages/middleware/payment-express-middleware.md) | No BRC100 call, wire or wallet-data migration. maxPaymentHeaderBytes is now ignored, including existing explicit values: move transport policy to the HTTP server, CDN, proxy or WAF and remove the deprecated option. Validate the complete HTTP route for supported payment proofs. Published 2.1.8 is verified against protected release 36052862859 and its immutable source artifacts. | -| `@bsv/sdk` | `2.8.7` | `2.8.8` | patch | [API and usage](../packages/sdk/bsv-sdk.md) | No API, wire or wallet-data migration. Omitting memoryLimit continues to leave local allocation unbounded. The default JavaScript interpreter still lacks block and coin heights and is not an exact node consensus validator. OP_NUM2BIN rejects sizes above the node's signed 32-bit limit before allocation; an optional finite memoryLimit bounds otherwise permitted local work before allocation. Resource exhaustion does not establish script invalidity. Published 2.8.7 remains the registry baseline until protected release. | +| `@bsv/sdk` | `2.8.8` | `2.8.9` | patch | [API and usage](../packages/sdk/bsv-sdk.md) | No API, wire or wallet-data migration. Omitting memoryLimit continues to leave local allocation unbounded. The default JavaScript interpreter still lacks block and coin heights and is not an exact node consensus validator. OP_NUM2BIN rejects sizes above the node's signed 32-bit limit before allocation; an optional finite memoryLimit bounds otherwise permitted local work before allocation. Resource exhaustion does not establish script invalidity. Published 2.8.8 is the registry baseline; source 2.8.9 remains a candidate until protected release. Update applications using full-size authenticated binary uploads to SDK 2.8.9. No API, wire or wallet-data migration is required; custom Peer transports retain their existing default policy. | | `@bsv/simple` | `0.6.0` | `0.6.0` | none | [API and usage](../packages/helpers/simple.md) | Replace createServerWalletHandler() deployments with createServerWalletHandler({ authorize: async ({ action, headers }) => authenticatedSessionCanUseAction(headers, action) }). The callback must return literal true for each status, create, request, receive, balance, outputs, or reset action; omission now returns HTTP 403 for every action. Roll out the authentication layer and callback with the package, update anonymous probes or automation, and apply the same policy to every replica. Do not emulate the old public behavior with an unconditional authorize: () => true callback. Valid recipient derivations and authenticated Message Box peers remain supported; malformed, wrong-owner, or transaction-mutated flows now fail closed. New DID, CredentialSchema, and Certifier records use canonical 32-byte types. Current SDK wallet methods reject historical short types, so do not put migration aliases in wallet list, acquire, prove, or relinquish calls. Export affected records through the storage version that created them, authenticate them offline against the exact locally configured identifier, and reissue/import canonical replacements; no legacy certificate is rewritten automatically. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. The internal comparator consolidation requires no migration and is included in the existing unpublished 0.6.0 candidate. | | `@bsv/templates` | `1.10.2` | `1.10.3` | patch | [API and usage](../packages/helpers/templates.md) | None. CommonJS consumers that patched 1.10.2 locally can drop the patch after upgrading to 1.10.3; ESM consumers are unaffected. | | `@bsv/teranode-listener` | `1.1.6` | `1.1.6` | none | [API and usage](../packages/network/teranode-listener.md) | No API migration is required for valid consumers: raw callbacks remain the default and decoding is opt-in with decodeMessages: true. Configuration arrays and callbacks are snapshotted at construction, boolean controls must be literal booleans, and malformed or duplicate topics, addresses, keys, and unsupported properties now fail closed. usePrivateDHT: false now actually omits the DHT service. The published mainnet PNET value is transport compatibility data, not a publisher credential; decoded sender and payload fields remain untrusted and security-critical claims require independent validation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | @@ -359,8 +359,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/sdk/bsv-sdk.md](../packages/sdk/bsv-sdk.md) - Source: [packages/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) -- Release note: Aligns explicit Script verification with bitcoin-sv coin-era, Chronicle version, locktime, sequence, signature-hash and numeric rules, including original-digest serialization. Rejects node-invalid OP_NUM2BIN sizes and checks an optional local memory budget before allocation. -- Migration: No API, wire or wallet-data migration. Omitting memoryLimit continues to leave local allocation unbounded. The default JavaScript interpreter still lacks block and coin heights and is not an exact node consensus validator. OP_NUM2BIN rejects sizes above the node's signed 32-bit limit before allocation; an optional finite memoryLimit bounds otherwise permitted local work before allocation. Resource exhaustion does not establish script invalidity. Published 2.8.7 remains the registry baseline until protected release. +- Release note: Aligns explicit Script verification with bitcoin-sv coin-era, Chronicle version, locktime, sequence, signature-hash and numeric rules, including original-digest serialization. Rejects node-invalid OP_NUM2BIN sizes and checks an optional local memory budget before allocation. AuthFetch delegates general binary payload capacity to its bounded HTTP transport, allowing full 4 MiB CHIRP chunks without charging their JSON expansion against the generic envelope budget. Existing HTTP, handshake, certificate, nonce, signature and redirect limits remain enforced. +- Migration: No API, wire or wallet-data migration. Omitting memoryLimit continues to leave local allocation unbounded. The default JavaScript interpreter still lacks block and coin heights and is not an exact node consensus validator. OP_NUM2BIN rejects sizes above the node's signed 32-bit limit before allocation; an optional finite memoryLimit bounds otherwise permitted local work before allocation. Resource exhaustion does not establish script invalidity. Published 2.8.8 is the registry baseline; source 2.8.9 remains a candidate until protected release. Update applications using full-size authenticated binary uploads to SDK 2.8.9. No API, wire or wallet-data migration is required; custom Peer transports retain their existing default policy. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index 8caeeab65..8db50bd4a 100644 --- a/docs/reference/stack-facts.md +++ b/docs/reference/stack-facts.md @@ -62,7 +62,7 @@ authorized release action. | overlays | `@bsv/overlay-discovery-services` | `2.2.6` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay-discovery-services](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-discovery-services) | | overlays | `@bsv/overlay-express` | `2.7.3` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay-express](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-express) | | overlays | `@bsv/overlay-topics` | `1.9.1` | node-library | node-esm | node | `>=22` | [packages/overlays/topics](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/topics) | -| sdk | `@bsv/sdk` | `2.8.8` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) | +| sdk | `@bsv/sdk` | `2.8.9` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) | | sdk | `@bsv/verifast` | `0.3.6` | 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.3` | 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.2.1` | 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) | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 90890abd0..97f80ccf6 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -166,10 +166,10 @@ }, { "name": "@bsv/sdk", - "publishedVersion": "2.8.7", + "publishedVersion": "2.8.8", "releaseType": "patch", - "summary": "Aligns explicit Script verification with bitcoin-sv coin-era, Chronicle version, locktime, sequence, signature-hash and numeric rules, including original-digest serialization. Rejects node-invalid OP_NUM2BIN sizes and checks an optional local memory budget before allocation.", - "migration": "No API, wire or wallet-data migration. Omitting memoryLimit continues to leave local allocation unbounded. The default JavaScript interpreter still lacks block and coin heights and is not an exact node consensus validator. OP_NUM2BIN rejects sizes above the node's signed 32-bit limit before allocation; an optional finite memoryLimit bounds otherwise permitted local work before allocation. Resource exhaustion does not establish script invalidity. Published 2.8.7 remains the registry baseline until protected release." + "summary": "Aligns explicit Script verification with bitcoin-sv coin-era, Chronicle version, locktime, sequence, signature-hash and numeric rules, including original-digest serialization. Rejects node-invalid OP_NUM2BIN sizes and checks an optional local memory budget before allocation. AuthFetch delegates general binary payload capacity to its bounded HTTP transport, allowing full 4 MiB CHIRP chunks without charging their JSON expansion against the generic envelope budget. Existing HTTP, handshake, certificate, nonce, signature and redirect limits remain enforced.", + "migration": "No API, wire or wallet-data migration. Omitting memoryLimit continues to leave local allocation unbounded. The default JavaScript interpreter still lacks block and coin heights and is not an exact node consensus validator. OP_NUM2BIN rejects sizes above the node's signed 32-bit limit before allocation; an optional finite memoryLimit bounds otherwise permitted local work before allocation. Resource exhaustion does not establish script invalidity. Published 2.8.8 is the registry baseline; source 2.8.9 remains a candidate until protected release. Update applications using full-size authenticated binary uploads to SDK 2.8.9. No API, wire or wallet-data migration is required; custom Peer transports retain their existing default policy." }, { "name": "@bsv/simple", diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index 052086b68..dcfa26d58 100644 --- a/governance/repository-health/baselines.json +++ b/governance/repository-health/baselines.json @@ -322,7 +322,7 @@ "@bsv/overlay-discovery-services": "2.2.6", "@bsv/overlay-express": "2.7.3", "@bsv/overlay-topics": "1.9.1", - "@bsv/sdk": "2.8.8", + "@bsv/sdk": "2.8.9", "@bsv/verifast": "0.3.6", "@bsv/btms": "1.2.3", "@bsv/btms-permission-module": "1.2.1", diff --git a/packages/network/chirp/test/uploader.authenticated.test.ts b/packages/network/chirp/test/uploader.authenticated.test.ts index 5fbe0c04c..4dc8973d8 100644 --- a/packages/network/chirp/test/uploader.authenticated.test.ts +++ b/packages/network/chirp/test/uploader.authenticated.test.ts @@ -5,83 +5,89 @@ import express from 'express' import { rateLimit } from 'express-rate-limit' import { CHIRPUploader, objectIdentifierForBytes } from '../src/index.js' -test('default AuthFetch uploads exact identity bytes with HTTP-managed length', async () => { - const clientWallet = new ProtoWallet(PrivateKey.fromRandom()) - const identity = (await clientWallet.getPublicKey({ identityKey: true })).publicKey - const staged = new Map() - const lengths: number[] = [] - const identities: string[] = [] - const app = express() - app.use(express.json({ limit: '1mb' })) - app.use(express.raw({ type: 'application/octet-stream', limit: '1mb' })) - app.use(rateLimit({ windowMs: 60_000, limit: 300 })) - app.use( - createAuthMiddleware({ - wallet: new ProtoWallet(PrivateKey.fromRandom()), - allowUnauthenticated: false +test.each<[string, Uint8Array]>([ + ['non-text bytes', Uint8Array.of(0, 1, 127, 128, 255)], + ['complete 4 MiB chunk', new Uint8Array(4_194_304).fill(255)] +])( + 'default AuthFetch uploads %s with exact identity bytes and HTTP-managed length', + async (_name, source) => { + const clientWallet = new ProtoWallet(PrivateKey.fromRandom()) + const identity = (await clientWallet.getPublicKey({ identityKey: true })).publicKey + const staged = new Map() + const lengths: number[] = [] + const identities: string[] = [] + const app = express() + app.use(express.json({ limit: '1mb' })) + app.use(express.raw({ type: 'application/octet-stream', limit: 4_194_304 })) + app.use(rateLimit({ windowMs: 60_000, limit: 300 })) + app.use( + createAuthMiddleware({ + wallet: new ProtoWallet(PrivateKey.fromRandom()), + allowUnauthenticated: false + }) + ) + app.use(rateLimit({ windowMs: 60_000, limit: 1000 })) + app.use((req, _res, next) => { + identities.push((req as typeof req & { auth: { identityKey: string } }).auth.identityKey) + next() }) - ) - app.use(rateLimit({ windowMs: 60_000, limit: 1000 })) - app.use((req, _res, next) => { - identities.push((req as typeof req & { auth: { identityKey: string } }).auth.identityKey) - next() - }) - app.post('/chirp/v1/uploads', (_req, res) => { - res.status(201).json({ uploadId: 'authenticated-session', stagingExpiresAt: '4000000000' }) - }) - const objectRoute = '/chirp/v1/uploads/:uploadId/objects/:objectIdentifier' - app.head(objectRoute, (req, res) => { - res.status(staged.has(req.params.objectIdentifier) ? 200 : 404).end() - }) - app.put(objectRoute, (req, res) => { - const bytes: unknown = req.body - expect(Buffer.isBuffer(bytes)).toBe(true) - if (!Buffer.isBuffer(bytes)) throw new Error('Expected authenticated raw bytes') - const byteLength = Buffer.byteLength(bytes) - expect(req.get('content-encoding') ?? 'identity').toBe('identity') - expect(req.get('content-type')).toBe('application/octet-stream') - expect(Number(req.get('content-length'))).toBe(byteLength) - expect(objectIdentifierForBytes(bytes)).toBe(req.params.objectIdentifier) - staged.set(req.params.objectIdentifier, bytes) - lengths.push(byteLength) - res.status(201).end() - }) - let origin = '' - app.post('/chirp/v1/uploads/:uploadId/commit', (req, res) => { - const root = (req.body as { rootIdentifier: string }).rootIdentifier - expect(staged.has(root)).toBe(true) - res.status(201).json({ - chirpURL: `chirp://${root}`, - uhrpURL: `uhrp://${root}`, - hostedFileLocation: `${origin}/chirp/v1/${root}/objects/${root}`, - expiryTime: 4_000_000_000 + app.post('/chirp/v1/uploads', (_req, res) => { + res.status(201).json({ uploadId: 'authenticated-session', stagingExpiresAt: '4000000000' }) }) - }) - const server = app.listen(0, '127.0.0.1') - try { - await new Promise(resolve => server.once('listening', resolve)) - const address = server.address() - if (address == null || typeof address === 'string') - throw new Error('Expected loopback listener') - origin = `http://127.0.0.1:${address.port}` - const source = Uint8Array.of(0, 1, 127, 128, 255) - const result = await new CHIRPUploader({ - wallet: clientWallet, - storageURL: origin, - allowPrivateHosts: true, - allowInsecureHTTP: true, - retriesPerRequest: 0 - }).publish({ source, retentionSeconds: 60 }) - expect(result.commits).toHaveLength(1) - expect(lengths).toHaveLength(2) - expect(lengths).toContain(source.length) - expect(staged.get(objectIdentifierForBytes(source))).toEqual(Buffer.from(source)) - expect(identities.length).toBeGreaterThanOrEqual(6) - expect(identities.every(value => value === identity)).toBe(true) - } finally { - server.closeAllConnections() - await new Promise((resolve, reject) => - server.close(error => (error == null ? resolve() : reject(error))) - ) - } -}, 20_000) + const objectRoute = '/chirp/v1/uploads/:uploadId/objects/:objectIdentifier' + app.head(objectRoute, (req, res) => { + res.status(staged.has(req.params.objectIdentifier) ? 200 : 404).end() + }) + app.put(objectRoute, (req, res) => { + const bytes: unknown = req.body + expect(Buffer.isBuffer(bytes)).toBe(true) + if (!Buffer.isBuffer(bytes)) throw new Error('Expected authenticated raw bytes') + const byteLength = Buffer.byteLength(bytes) + expect(req.get('content-encoding') ?? 'identity').toBe('identity') + expect(req.get('content-type')).toBe('application/octet-stream') + expect(Number(req.get('content-length'))).toBe(byteLength) + expect(objectIdentifierForBytes(bytes)).toBe(req.params.objectIdentifier) + staged.set(req.params.objectIdentifier, bytes) + lengths.push(byteLength) + res.status(201).end() + }) + let origin = '' + app.post('/chirp/v1/uploads/:uploadId/commit', (req, res) => { + const root = (req.body as { rootIdentifier: string }).rootIdentifier + expect(staged.has(root)).toBe(true) + res.status(201).json({ + chirpURL: `chirp://${root}`, + uhrpURL: `uhrp://${root}`, + hostedFileLocation: `${origin}/chirp/v1/${root}/objects/${root}`, + expiryTime: 4_000_000_000 + }) + }) + const server = app.listen(0, '127.0.0.1') + try { + await new Promise(resolve => server.once('listening', resolve)) + const address = server.address() + if (address == null || typeof address === 'string') + throw new Error('Expected loopback listener') + origin = `http://127.0.0.1:${address.port}` + const result = await new CHIRPUploader({ + wallet: clientWallet, + storageURL: origin, + allowPrivateHosts: true, + allowInsecureHTTP: true, + retriesPerRequest: 0 + }).publish({ source, retentionSeconds: 60 }) + expect(result.commits).toHaveLength(1) + expect(lengths).toHaveLength(2) + expect(lengths).toContain(source.length) + expect(staged.get(objectIdentifierForBytes(source))).toEqual(Buffer.from(source)) + expect(identities.length).toBeGreaterThanOrEqual(6) + expect(identities.every(value => value === identity)).toBe(true) + } finally { + server.closeAllConnections() + await new Promise((resolve, reject) => + server.close(error => (error == null ? resolve() : reject(error))) + ) + } + }, + 60_000 +) diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 74c78b712..62335c028 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -214,6 +214,10 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +### Fixed (2.8.9 candidate) + +- AuthFetch delegates general binary payload capacity to its bounded HTTP transport instead of charging each byte against the generic JSON authentication envelope budget. Complete 4 MiB CHIRP chunks now reach the transport; HTTP request/response bounds, authentication checks, certificate limits and redirect rejection remain enforced. No API, wire or wallet-data migration is required. + ### Fixed (2.8.8 candidate) - Align explicit Script verification with bitcoin-sv's coin-era and Chronicle version gates, CLTV/CSV, historical signature hashing, original-digest serialization, numeric widths and shift bounds. Node-derived transaction fixtures cover each corrected mismatch. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index c14ede428..0112cc165 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -34,6 +34,13 @@ the most recent 1,000 entries. Simplified authenticated HTTP frames, bodies, headers, signatures, request IDs, and certificate-request headers have fixed size/count limits and redirects are rejected. +SDK 2.8.9 lets AuthFetch use the existing HTTP transport byte limits for +binary application payloads. A full 4 MiB CHIRP chunk no longer consumes the +generic authentication envelope's JSON expansion budget before HTTP dispatch. +Request framing remains capped at 16 MiB, configured response body limits remain +enforced, and handshake, certificate, signature, nonce and redirect checks are +unchanged. No API, wire or wallet-data migration is required. + For signature payloads of at least 64 KiB, `ProtoWallet` uses asynchronous platform SHA-256 when Web Crypto is available, avoiding long synchronous hashing on browser UI threads. Unsupported or failed native hashing falls back diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 6e0307bea..b92c208d3 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/sdk", - "version": "2.8.8", + "version": "2.8.9", "sideEffects": false, "engines": { "node": ">=22" diff --git a/packages/sdk/src/auth/clients/AuthFetch.ts b/packages/sdk/src/auth/clients/AuthFetch.ts index 33592085a..c758cb320 100644 --- a/packages/sdk/src/auth/clients/AuthFetch.ts +++ b/packages/sdk/src/auth/clients/AuthFetch.ts @@ -347,7 +347,10 @@ export class AuthFetch { this.requestedCertificates, this.sessionManager, undefined, - this.originator + this.originator, + // The HTTP transport bounds framed binary bytes; the generic JSON + // envelope budget would charge each byte as four JSON characters. + { maxGeneralPayloadBytes: null } ) await newPeer.ready const peerState: AuthPeer = { diff --git a/packages/sdk/src/auth/clients/__tests__/AuthFetch.peer.test.ts b/packages/sdk/src/auth/clients/__tests__/AuthFetch.peer.test.ts index 1090cb268..a055acabb 100644 --- a/packages/sdk/src/auth/clients/__tests__/AuthFetch.peer.test.ts +++ b/packages/sdk/src/auth/clients/__tests__/AuthFetch.peer.test.ts @@ -94,7 +94,8 @@ describe('AuthFetch authenticated peer lifecycle', () => { undefined, expect.anything(), undefined, - 'app.example' + 'app.example', + { maxGeneralPayloadBytes: null } ) }) @@ -171,7 +172,8 @@ describe('AuthFetch authenticated peer lifecycle', () => { requestedCertificates, expect.anything(), undefined, - 'app.example' + 'app.example', + { maxGeneralPayloadBytes: null } ) expect(response.status).toBe(201) expect(response.headers.get('x-test')).toBe('passed') diff --git a/packages/sdk/src/auth/transports/__tests__/SimplifiedFetchTransport.test.ts b/packages/sdk/src/auth/transports/__tests__/SimplifiedFetchTransport.test.ts index 1a3525f68..559cb85de 100644 --- a/packages/sdk/src/auth/transports/__tests__/SimplifiedFetchTransport.test.ts +++ b/packages/sdk/src/auth/transports/__tests__/SimplifiedFetchTransport.test.ts @@ -53,6 +53,19 @@ afterEach(() => { }) describe('SimplifiedFetchTransport send', () => { + test('rejects a framed request above 16 MiB before any HTTP dispatch', async () => { + const fetchMock: jest.MockedFunction = jest.fn() + const transport = new SimplifiedFetchTransport('https://api.example.com', fetchMock) + await transport.onData(async () => {}) + // Declared length suffices: the transport rejects before reading or allocating bytes. + const payload: number[] = [] + payload.length = 16 * 1024 * 1024 + 1 + await expect(transport.send(createGeneralMessage({ payload }))).rejects.toThrow( + 'Authenticated request payload exceeds the configured limit' + ) + expect(fetchMock).not.toHaveBeenCalled() + }) + test('wraps network failures with context', async () => { const fetchMock: jest.MockedFunction = jest.fn() fetchMock.mockRejectedValue(new Error('network down')) From 6eb34251bf5b31c1d990ea181b8433e0a8361080 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sat, 26 Sep 2026 22:10:03 -0700 Subject: [PATCH 2/2] Bound AuthFetch binary payload capacity to HTTP framing budgets --- docs/reference/package-api-migrations.md | 2 +- governance/package-release-notes.json | 2 +- packages/sdk/CHANGELOG.md | 2 +- packages/sdk/README.md | 4 +- packages/sdk/src/auth/clients/AuthFetch.ts | 16 +++- .../clients/__tests__/AuthFetch.peer.test.ts | 77 +++++++++++-------- 6 files changed, 61 insertions(+), 42 deletions(-) diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 7fda55bc7..aa3e3868e 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -359,7 +359,7 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/sdk/bsv-sdk.md](../packages/sdk/bsv-sdk.md) - Source: [packages/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) -- Release note: Aligns explicit Script verification with bitcoin-sv coin-era, Chronicle version, locktime, sequence, signature-hash and numeric rules, including original-digest serialization. Rejects node-invalid OP_NUM2BIN sizes and checks an optional local memory budget before allocation. AuthFetch delegates general binary payload capacity to its bounded HTTP transport, allowing full 4 MiB CHIRP chunks without charging their JSON expansion against the generic envelope budget. Existing HTTP, handshake, certificate, nonce, signature and redirect limits remain enforced. +- Release note: Aligns explicit Script verification with bitcoin-sv coin-era, Chronicle version, locktime, sequence, signature-hash and numeric rules, including original-digest serialization. Rejects node-invalid OP_NUM2BIN sizes and checks an optional local memory budget before allocation. AuthFetch retains a finite general payload budget matching HTTP request and configured response capacity, allowing full 4 MiB CHIRP chunks without charging their JSON expansion against the generic envelope budget. Existing HTTP, handshake, certificate, nonce, signature and redirect limits remain enforced. - Migration: No API, wire or wallet-data migration. Omitting memoryLimit continues to leave local allocation unbounded. The default JavaScript interpreter still lacks block and coin heights and is not an exact node consensus validator. OP_NUM2BIN rejects sizes above the node's signed 32-bit limit before allocation; an optional finite memoryLimit bounds otherwise permitted local work before allocation. Resource exhaustion does not establish script invalidity. Published 2.8.8 is the registry baseline; source 2.8.9 remains a candidate until protected release. Update applications using full-size authenticated binary uploads to SDK 2.8.9. No API, wire or wallet-data migration is required; custom Peer transports retain their existing default policy. | Public subpath | Runtime target(s) | Declaration target(s) | diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 97f80ccf6..ddf4bffdd 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -168,7 +168,7 @@ "name": "@bsv/sdk", "publishedVersion": "2.8.8", "releaseType": "patch", - "summary": "Aligns explicit Script verification with bitcoin-sv coin-era, Chronicle version, locktime, sequence, signature-hash and numeric rules, including original-digest serialization. Rejects node-invalid OP_NUM2BIN sizes and checks an optional local memory budget before allocation. AuthFetch delegates general binary payload capacity to its bounded HTTP transport, allowing full 4 MiB CHIRP chunks without charging their JSON expansion against the generic envelope budget. Existing HTTP, handshake, certificate, nonce, signature and redirect limits remain enforced.", + "summary": "Aligns explicit Script verification with bitcoin-sv coin-era, Chronicle version, locktime, sequence, signature-hash and numeric rules, including original-digest serialization. Rejects node-invalid OP_NUM2BIN sizes and checks an optional local memory budget before allocation. AuthFetch retains a finite general payload budget matching HTTP request and configured response capacity, allowing full 4 MiB CHIRP chunks without charging their JSON expansion against the generic envelope budget. Existing HTTP, handshake, certificate, nonce, signature and redirect limits remain enforced.", "migration": "No API, wire or wallet-data migration. Omitting memoryLimit continues to leave local allocation unbounded. The default JavaScript interpreter still lacks block and coin heights and is not an exact node consensus validator. OP_NUM2BIN rejects sizes above the node's signed 32-bit limit before allocation; an optional finite memoryLimit bounds otherwise permitted local work before allocation. Resource exhaustion does not establish script invalidity. Published 2.8.8 is the registry baseline; source 2.8.9 remains a candidate until protected release. Update applications using full-size authenticated binary uploads to SDK 2.8.9. No API, wire or wallet-data migration is required; custom Peer transports retain their existing default policy." }, { diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 62335c028..90154819c 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -216,7 +216,7 @@ All notable changes to this project will be documented in this file. The format ### Fixed (2.8.9 candidate) -- AuthFetch delegates general binary payload capacity to its bounded HTTP transport instead of charging each byte against the generic JSON authentication envelope budget. Complete 4 MiB CHIRP chunks now reach the transport; HTTP request/response bounds, authentication checks, certificate limits and redirect rejection remain enforced. No API, wire or wallet-data migration is required. +- AuthFetch bounds general payloads in binary bytes using its HTTP request and configured response budgets instead of charging each byte against the generic JSON authentication envelope budget. Complete 4 MiB CHIRP chunks now reach the transport; HTTP request/response bounds, authentication checks, certificate limits and redirect rejection remain enforced. No API, wire or wallet-data migration is required. ### Fixed (2.8.8 candidate) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 0112cc165..09b12018e 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -34,8 +34,8 @@ the most recent 1,000 entries. Simplified authenticated HTTP frames, bodies, headers, signatures, request IDs, and certificate-request headers have fixed size/count limits and redirects are rejected. -SDK 2.8.9 lets AuthFetch use the existing HTTP transport byte limits for -binary application payloads. A full 4 MiB CHIRP chunk no longer consumes the +SDK 2.8.9 gives AuthFetch a finite binary payload budget matching its +existing HTTP request and configured response capacity. A full 4 MiB CHIRP chunk no longer consumes the generic authentication envelope's JSON expansion budget before HTTP dispatch. Request framing remains capped at 16 MiB, configured response body limits remain enforced, and handshake, certificate, signature, nonce and redirect checks are diff --git a/packages/sdk/src/auth/clients/AuthFetch.ts b/packages/sdk/src/auth/clients/AuthFetch.ts index c758cb320..06813f6a5 100644 --- a/packages/sdk/src/auth/clients/AuthFetch.ts +++ b/packages/sdk/src/auth/clients/AuthFetch.ts @@ -95,6 +95,8 @@ const MAX_AUTH_RESPONSE_HEADER_KEY_BYTES = 1024 const MAX_AUTH_RESPONSE_HEADER_VALUE_BYTES = 32 * 1024 const MAX_AUTH_RESPONSE_HEADER_BYTES = 256 * 1024 const MAX_AUTH_RESPONSE_FRAME_OVERHEAD_BYTES = 512 * 1024 +// Matches SimplifiedFetchTransport's fixed complete-request frame ceiling. +const MAX_AUTH_HTTP_REQUEST_FRAME_BYTES = 16 * 1024 * 1024 const MAX_PAYMENT_TRANSACTION_BYTES = 16 * 1024 * 1024 const REDACTED_LOG_VALUE = '[redacted]' @@ -348,9 +350,17 @@ export class AuthFetch { this.sessionManager, undefined, this.originator, - // The HTTP transport bounds framed binary bytes; the generic JSON - // envelope budget would charge each byte as four JSON characters. - { maxGeneralPayloadBytes: null } + // Budget binary bytes separately from the generic JSON envelope, + // retaining a finite bound before Peer snapshots either HTTP frame. + { + maxGeneralPayloadBytes: Math.max( + MAX_AUTH_HTTP_REQUEST_FRAME_BYTES, + Math.min( + Number.MAX_SAFE_INTEGER, + this.maxResponseBytes + MAX_AUTH_RESPONSE_FRAME_OVERHEAD_BYTES + ) + ) + } ) await newPeer.ready const peerState: AuthPeer = { diff --git a/packages/sdk/src/auth/clients/__tests__/AuthFetch.peer.test.ts b/packages/sdk/src/auth/clients/__tests__/AuthFetch.peer.test.ts index a055acabb..e20dbc9a4 100644 --- a/packages/sdk/src/auth/clients/__tests__/AuthFetch.peer.test.ts +++ b/packages/sdk/src/auth/clients/__tests__/AuthFetch.peer.test.ts @@ -62,42 +62,51 @@ afterEach(() => { }) describe('AuthFetch authenticated peer lifecycle', () => { - test('binds the configured originator when a certificate request creates a peer', async () => { - let certificatesReceived: - ((senderPublicKey: string, certificates: unknown[]) => void) | undefined - const peer = { - ready: Promise.resolve(), - listenForCertificatesReceived: jest.fn( - (listener: (senderPublicKey: string, certificates: unknown[]) => void) => { - certificatesReceived = listener - return 7 - } - ), - listenForCertificatesRequested: jest.fn(), - stopListeningForCertificatesReceived: jest.fn(), - requestCertificates: jest.fn(async () => { - certificatesReceived?.('server-key', []) + test.each([ + [4, 16 * 1024 * 1024], + [16 * 1024 * 1024, 16 * 1024 * 1024 + 512 * 1024], + [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER] + ])( + 'binds the originator and finite HTTP payload budget with response limit %i', + async (maxResponseBytes, payloadBudget) => { + let certificatesReceived: + ((senderPublicKey: string, certificates: unknown[]) => void) | undefined + const peer = { + ready: Promise.resolve(), + listenForCertificatesReceived: jest.fn( + (listener: (senderPublicKey: string, certificates: unknown[]) => void) => { + certificatesReceived = listener + return 7 + } + ), + listenForCertificatesRequested: jest.fn(), + stopListeningForCertificatesReceived: jest.fn(), + requestCertificates: jest.fn(async () => { + certificatesReceived?.('server-key', []) + }) + } + PeerMock.mockImplementation(() => peer) + const wallet = { getPublicKey: jest.fn() } + const authFetch = new AuthFetch(wallet as any, undefined, undefined, 'app.example' as any, { + maxResponseBytes }) - } - PeerMock.mockImplementation(() => peer) - const wallet = { getPublicKey: jest.fn() } - const authFetch = new AuthFetch(wallet as any, undefined, undefined, 'app.example' as any) - await authFetch.sendCertificateRequest('https://service.example/certificates', { - certifiers: [], - types: {} - }) + await authFetch.sendCertificateRequest('https://service.example/certificates', { + certifiers: [], + types: {} + }) - expect(PeerMock).toHaveBeenCalledWith( - wallet, - expect.anything(), - undefined, - expect.anything(), - undefined, - 'app.example', - { maxGeneralPayloadBytes: null } - ) - }) + expect(PeerMock).toHaveBeenCalledWith( + wallet, + expect.anything(), + undefined, + expect.anything(), + undefined, + 'app.example', + { maxGeneralPayloadBytes: payloadBudget } + ) + } + ) test('creates a peer, exchanges certificates, and resolves an authenticated response', async () => { let certificatesReceived: @@ -173,7 +182,7 @@ describe('AuthFetch authenticated peer lifecycle', () => { expect.anything(), undefined, 'app.example', - { maxGeneralPayloadBytes: null } + { maxGeneralPayloadBytes: 16 * 1024 * 1024 + 512 * 1024 } ) expect(response.status).toBe(201) expect(response.headers.get('x-test')).toBe('passed')