From 42f92356291186ffc8edb98832b076a371a36fb0 Mon Sep 17 00:00:00 2001 From: Elis Jackson Date: Mon, 21 Sep 2026 05:01:34 -0500 Subject: [PATCH 1/2] fix(sdk): encode an empty BRC-104 response body as -1, not 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BRC-104 §6.7.3 requires an absent or empty body to be encoded as a length of -1, and §6.9 lists the response signature preimage's final field as 'Body length + body bytes (or -1 if none)'. AuthFetch's own request side already implements this (writeRequestBody and writeOptionalText both write -1), but writeGeneralResponsePayload encoded an empty body as 0 with no -1 branch. A conforming counterparty therefore signs a preimage this client can never reproduce, so every signed response with no body — a bare 404, 204 or empty 401/403 — fails signature verification with 'Signature is not valid'. Regression tests pin the terminal varint to exactly -1 with nothing following it for an empty body, and to true length plus bytes otherwise; the empty-body case fails against the previous encoding. AuthFetch's response reader already treats a non-positive length as no body, so verified traffic is unchanged apart from now verifying. Signed-off-by: Elis Jackson --- .../transports/SimplifiedFetchTransport.ts | 13 +++- ...implifiedFetchTransport.additional.test.ts | 59 +++++++++++++++++++ specs/auth/brc103-mutual-auth.yaml | 3 +- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts b/packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts index dd52535f1..d61348f73 100644 --- a/packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts +++ b/packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts @@ -192,8 +192,17 @@ export class SimplifiedFetchTransport implements Transport { writer.writeVarIntNum(valueBytes.length) writer.write(valueBytes) } - writer.writeVarIntNum(body.length) - if (body.length > 0) writer.write(body) + // BRC-104 §6.7.3/§6.9: an absent or empty body is encoded as -1, not as a + // zero length — the rule AuthFetch's writeRequestBody and + // writeOptionalText already apply to ABSENT values on the request side. + // Encoding 0 here made every signed bodyless response (a bare 404, 204 + // or empty 401/403) fail verification against a conforming counterparty. + if (body.length === 0) { + writer.writeVarIntNum(-1) + } else { + writer.writeVarIntNum(body.length) + writer.write(body) + } return writer.toArray() } diff --git a/packages/sdk/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts b/packages/sdk/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts index f602ae1bd..3f5dbfff3 100644 --- a/packages/sdk/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts +++ b/packages/sdk/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts @@ -917,3 +917,62 @@ describe('SimplifiedFetchTransport callback containment', () => { ) }) }) + +describe('writeGeneralResponsePayload — BRC-104 body-length encoding', () => { + // BRC-104 §6.7.3: "If the body is empty, specify a length of -1 in the + // payload"; §6.9 lists the response preimage's final field as "Body length + // + body bytes (or -1 if none)". The request side (AuthFetch's + // writeRequestBody / writeOptionalText) already encodes -1; these pin the + // response side to the same rule. Asserting the exact value is the + // regression guard: under the pre-fix encoding the terminal varint read 0, + // so either test going green with that encoding is impossible. + const makeTransport = (): any => + new SimplifiedFetchTransport('https://api.example.com', jest.fn() as any) + + test('an empty body encodes as -1 with no trailing bytes', () => { + const response = new Response(null, { status: 404 }) + const payload: number[] = (makeTransport() as any).writeGeneralResponsePayload( + response, + [] + ) + const reader = new Utils.Reader(payload) + expect(reader.readVarIntNum()).toBe(404) // status + expect(reader.readVarIntNum()).toBe(0) // no signed headers + expect(reader.readVarIntNum()).toBe(-1) // empty body is -1, not 0 + expect(reader.pos).toBe(payload.length) // and nothing follows it + }) + + test('an empty body is still -1 when a request id precedes it', () => { + const requestIdBytes = Array.from({ length: 32 }, (_, i) => i) + const requestId = Utils.toBase64(requestIdBytes) + const response = new Response(null, { + status: 404, + headers: { 'x-bsv-auth-request-id': requestId } + }) + const payload: number[] = (makeTransport() as any).writeGeneralResponsePayload( + response, + [] + ) + const reader = new Utils.Reader(payload) + expect(reader.read(32)).toEqual(requestIdBytes) + expect(reader.readVarIntNum()).toBe(404) + expect(reader.readVarIntNum()).toBe(0) + expect(reader.readVarIntNum()).toBe(-1) + expect(reader.pos).toBe(payload.length) + }) + + test('a non-empty body still encodes its true length and bytes', () => { + const body = [1, 2, 3] + const response = new Response(null, { status: 404 }) + const payload: number[] = (makeTransport() as any).writeGeneralResponsePayload( + response, + body + ) + const reader = new Utils.Reader(payload) + expect(reader.readVarIntNum()).toBe(404) + expect(reader.readVarIntNum()).toBe(0) + expect(reader.readVarIntNum()).toBe(3) + expect(reader.read(3)).toEqual(body) + expect(reader.pos).toBe(payload.length) + }) +}) diff --git a/specs/auth/brc103-mutual-auth.yaml b/specs/auth/brc103-mutual-auth.yaml index 188055380..853f5842b 100644 --- a/specs/auth/brc103-mutual-auth.yaml +++ b/specs/auth/brc103-mutual-auth.yaml @@ -186,7 +186,8 @@ components: description: | For `general` messages: the signed application payload. Encoding: `requestId(32) || VarInt(statusCode) || VarInt(nHeaders) - || [header pairs] || VarInt(bodyLength) || body`. + || [header pairs] || VarInt(bodyLength) || body` (bodyLength is -1 + with no body bytes when the body is empty). Absent from SDK initialRequest and initialResponse messages. signature: type: array From 0260a489d57682ae407de25b07923625e35c4b97 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Tue, 22 Sep 2026 18:17:21 -0700 Subject: [PATCH 2/2] test(auth): verify BRC-104 empty responses through public transports --- conformance/META.json | 4 +- conformance/PARITY_MATRIX.json | 12 +-- conformance/runner/ts/dispatchers/auth.ts | 48 ++++++++- conformance/vectors/auth/brc31-handshake.json | 72 +++++++++++++- docs/reference/package-api-migrations.md | 6 +- docs/reference/stack-facts.md | 8 +- governance/package-release-notes.json | 4 +- governance/repository-health/baselines.json | 6 +- .../src/__tests/integration.test.ts | 26 +++++ .../src/__tests/testExpressServer.ts | 6 ++ packages/sdk/CHANGELOG.md | 5 + packages/sdk/README.md | 5 + .../transports/SimplifiedFetchTransport.ts | 7 +- ...implifiedFetchTransport.additional.test.ts | 99 ++++++++----------- 14 files changed, 224 insertions(+), 84 deletions(-) diff --git a/conformance/META.json b/conformance/META.json index 4318fb369..4e39d1b2b 100644 --- a/conformance/META.json +++ b/conformance/META.json @@ -73,8 +73,8 @@ }, "stats": { "total_files": 77, - "total_vectors": 6694, - "last_updated": "2026-09-09" + "total_vectors": 6699, + "last_updated": "2026-09-23" }, "regression_index": { "beef-v2-txid-panic": "go-sdk#306", diff --git a/conformance/PARITY_MATRIX.json b/conformance/PARITY_MATRIX.json index 94c00f805..874955ba1 100644 --- a/conformance/PARITY_MATRIX.json +++ b/conformance/PARITY_MATRIX.json @@ -1,21 +1,21 @@ { "schema_version": "1.0", - "generated_at": "2026-09-09", + "generated_at": "2026-09-23", "source": "ts-stack conformance corpus", "description": "Machine-readable parity status for cross-language SDK implementations (Go, Rust, Python). Use this to track and drive conformance.", "summary": { "total_files": 77, - "total_vectors": 6694, + "total_vectors": 6699, "fully_required_files": 58, "files_with_intended": 17, "files_with_mixed_status": 15, "vectors_by_status": { - "required": 6490, + "required": 6495, "intended": 204, "skipped": 7 }, "by_reason_category": { - "fully_supported": 1278, + "fully_supported": 1283, "governed_vector_skip": 50, "historical_regression": 36, "partial_ts_behavioral_difference": 5116, @@ -27,10 +27,10 @@ { "path": "auth/brc31-handshake.json", "id": "auth.brc31-handshake", - "total_vectors": 16, + "total_vectors": 21, "file_level_parity": "required", "effective_status": "required", - "required_count": 16, + "required_count": 21, "intended_count": 0, "skipped_count": 0, "reason_category": "fully_supported", diff --git a/conformance/runner/ts/dispatchers/auth.ts b/conformance/runner/ts/dispatchers/auth.ts index a50e48971..eeaa7610c 100644 --- a/conformance/runner/ts/dispatchers/auth.ts +++ b/conformance/runner/ts/dispatchers/auth.ts @@ -8,6 +8,7 @@ */ import { expect } from '@jest/globals' +import { SimplifiedFetchTransport, Utils } from '@bsv/sdk' export const categories: ReadonlyArray = ['brc31-handshake'] @@ -368,10 +369,55 @@ export function dispatch( throw new Error(`auth dispatcher: unknown category '${category}'`) } +async function dispatchResponsePreimage( + input: Record, + expected: Record +): Promise { + const requestId = Utils.toArray(getString(input, 'request_id_hex'), 'hex') + const body = Utils.toArray(getString(input, 'body_hex'), 'hex') + const identityKey = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798' + const response = new Response(body.length === 0 ? null : new Uint8Array(body), { + status: input['status'] as number, + headers: { + 'x-bsv-auth-version': '0.1', + 'x-bsv-auth-identity-key': identityKey, + 'x-bsv-auth-request-id': Utils.toBase64(requestId), + 'x-bsv-auth-signature': 'aabbcc' + } + }) + const transport = new SimplifiedFetchTransport('https://fixture.invalid', async () => response) + let received = 0 + await transport.onData(async message => { + expect(Utils.toHex(message.payload!)).toBe(expected['payload_hex']) + received++ + }) + const request = new Utils.Writer() + request.write(requestId) + for (const field of ['GET', '/api/resource']) { + const bytes = Utils.toArray(field, 'utf8') + request.writeVarIntNum(bytes.length) + request.write(bytes) + } + request.writeVarIntNum(-1) // absent query + request.writeVarIntNum(0) // no signed headers + request.writeVarIntNum(-1) // absent body + await transport.send({ + version: '0.1', + messageType: 'general', + identityKey, + nonce: 'bm9uY2U=', + yourNonce: 'bm9uY2U=', + signature: [1], + payload: request.toArray() + }) + expect(received).toBe(1) +} + function dispatchBRC31Handshake( input: Record, expected: Record -): void { +): void | Promise { + if (input['http_response_preimage'] === true) return dispatchResponsePreimage(input, expected) // Route by the path of the request (for HTTP vectors) or by special keys const path = getString(input, 'path') const schemaCheck = getBool(input, '_schema_check') diff --git a/conformance/vectors/auth/brc31-handshake.json b/conformance/vectors/auth/brc31-handshake.json index a0e016be9..d76e1fc04 100644 --- a/conformance/vectors/auth/brc31-handshake.json +++ b/conformance/vectors/auth/brc31-handshake.json @@ -3,7 +3,7 @@ "id": "auth.brc31-handshake", "name": "BRC-103 Mutual Authentication and BRC-104 HTTP Transport", "brc": ["BRC-103", "BRC-104"], - "version": "1.1.0", + "version": "1.2.0", "reference_impl": "packages/sdk", "parity_class": "required", "vectors": [ @@ -426,6 +426,76 @@ } }, "tags": ["brc-103", "phase-2", "error", "signing-failure"] + }, + { + "id": "auth.brc31-handshake.17", + "description": "BRC-104 HTTP 204 response preimage with an empty body", + "input": { + "http_response_preimage": true, + "status": 204, + "body_hex": "", + "request_id_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + }, + "expected": { + "payload_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fcc00ffffffffffffffffff" + }, + "tags": ["brc-104", "response", "wire-bytes"] + }, + { + "id": "auth.brc31-handshake.18", + "description": "BRC-104 HTTP 401 response preimage with an empty body", + "input": { + "http_response_preimage": true, + "status": 401, + "body_hex": "", + "request_id_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + }, + "expected": { + "payload_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1ffd910100ffffffffffffffffff" + }, + "tags": ["brc-104", "response", "wire-bytes"] + }, + { + "id": "auth.brc31-handshake.19", + "description": "BRC-104 HTTP 403 response preimage with an empty body", + "input": { + "http_response_preimage": true, + "status": 403, + "body_hex": "", + "request_id_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + }, + "expected": { + "payload_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1ffd930100ffffffffffffffffff" + }, + "tags": ["brc-104", "response", "wire-bytes"] + }, + { + "id": "auth.brc31-handshake.20", + "description": "BRC-104 HTTP 404 response preimage with an empty body", + "input": { + "http_response_preimage": true, + "status": 404, + "body_hex": "", + "request_id_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + }, + "expected": { + "payload_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1ffd940100ffffffffffffffffff" + }, + "tags": ["brc-104", "response", "wire-bytes"] + }, + { + "id": "auth.brc31-handshake.21", + "description": "BRC-104 HTTP 404 response preimage with three raw body bytes", + "input": { + "http_response_preimage": true, + "status": 404, + "body_hex": "010203", + "request_id_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + }, + "expected": { + "payload_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1ffd94010003010203" + }, + "tags": ["brc-104", "response", "wire-bytes"] } ] } diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index cb1137120..963141b15 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.8.0` | `1.8.4` | patch | [API and usage](../packages/overlays/overlay-topics.md) | Topic and lookup identifiers and valid canonical wire encodings remain unchanged. Audit historical rows for malformed amounts, noncanonical identifiers, incomplete ownership or admin evidence, and ambiguous outpoint linkage before replay or rebuild. Custom state and screening providers must return exact booleans, compare identity keys case-insensitively where documented, conserve exact safe-integer value, and honor bounded query and result contracts. | | `@bsv/paymail` | `2.4.2` | `2.4.9` | patch | [API and usage](../packages/messaging/paymail.md) | Valid public APIs and deployed Paymail wire shapes remain supported; malformed, ambiguous, oversized, local-network, wrong-owner, mutation-backed, or request-mismatched input now fails closed. Production origins and capability endpoints must be credential-free public HTTPS, except exact localhost development; custom transports and resolver configuration must use the documented exact runtime types. Receive-route verifySignature defaults to false for legacy compatibility, so unsigned metadata must not authorize a sender. Even when enabled, the legacy P2P signature authenticates only the transaction ID, not recipient, reference, sender-handle context, endpoint, freshness, or replay state. The historical 6745385c3fc0 advertisement is this package's compatibility behavior and is not the upstream signed/timestamped Basic Address Resolution assurance; a complete correction needs a versioned wire migration. Transaction Negotiation v1 is unauthenticated public input. Handlers must validate outputs, references, thread/freshness policy, proofs, callbacks, and durable replay state before financial or authorization effects. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/payment-express-middleware` | `2.1.1` | `2.1.7` | patch | [API and usage](../packages/middleware/payment-express-middleware.md) | No wire or public API migration is required; legacy x-bsv-payment JSON behavior remains supported, and Express 4 and 5 applications use their own peer-provided Express installation. Wallet adapters must return accepted and optional isMerge as own data properties; inherited/accessor-backed verdicts now fail closed. Overinclusive Atomic BEEF receipts are normalized to the declared subject closure. Production replicas must share one durable atomic replay store, and operators must reconcile a replay-store failure after wallet acceptance before asking a payer to spend again. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/sdk` | `2.7.1` | `2.8.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Existing TOTP.generate and TOTP.validate calls retain their historical two-digit, unpadded behavior and require no wire migration. New authentication flows should use generateSecure and validateSecure and store or transmit the six-character code as a string so leading zeroes are preserved. Ordinary valid BEEF, BRC-103 v0.1 peers, and public APIs remain compatible; malformed, ambiguous, oversized, identity-mismatched, or value-creating results now fail closed. Validated wallet results retain ordinary object behavior but are returned as owned value snapshots, so callers must not rely on object, array, or byte-buffer identity with the wallet adapter's raw response. Historical numeric-key JSON objects are recovered as bytes only for documented HTTP wallet byte fields; opaque numeric-key metadata remains an object. Deferred signableTransaction results may remain partial, and completed createAction results may use source values from the caller's immutable inputBEEF. Custom wallets must include direct source transactions for every other completed createAction or signAction input; duplicate input outpoints and unresolved or zero-input value-creating completed results are rejected. Browser applications that require DNS rebinding resistance must use a trusted egress proxy. | +| `@bsv/sdk` | `2.7.1` | `2.8.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | Existing TOTP.generate and TOTP.validate calls retain their historical two-digit, unpadded behavior and require no wire migration. New authentication flows should use generateSecure and validateSecure and store or transmit the six-character code as a string so leading zeroes are preserved. Ordinary valid BEEF, BRC-103 v0.1 peers, and public APIs remain compatible; malformed, ambiguous, oversized, identity-mismatched, or value-creating results now fail closed. Validated wallet results retain ordinary object behavior but are returned as owned value snapshots, so callers must not rely on object, array, or byte-buffer identity with the wallet adapter's raw response. Historical numeric-key JSON objects are recovered as bytes only for documented HTTP wallet byte fields; opaque numeric-key metadata remains an object. Deferred signableTransaction results may remain partial, and completed createAction results may use source values from the caller's immutable inputBEEF. Custom wallets must include direct source transactions for every other completed createAction or signAction input; duplicate input outpoints and unresolved or zero-input value-creating completed results are rejected. Browser applications that require DNS rebinding resistance must use a trusted egress proxy. The response-encoding correction is included in the existing unpublished 2.8.0 candidate. Conforming servers and non-empty response bytes require no migration; non-conforming servers signing zero for an empty response must use the BRC-104 -1 sentinel. | | `@bsv/simple` | `0.5.3` | `0.6.0` | minor | [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/. | | `@bsv/templates` | `1.9.1` | `1.10.2` | minor | [API and usage](../packages/helpers/templates.md) | No API migration is required for valid templates. MandalaToken now rejects locking or decoding amounts outside JavaScript's positive safe-integer range; audit any previously accepted non-exact amount scripts before replay. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/teranode-listener` | `1.1.1` | `1.1.6` | patch | [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: Adds TOTP.generateSecure and TOTP.validateSecure for conventional six-digit zero-padded codes while retaining the published legacy methods, and hardens authenticated identity binding, replay state, transaction framing, BEEF ownership, registry payloads, transport deadlines, script-verifier registration, wallet-result ownership, certificate acquisition, and signing context. -- Migration: Existing TOTP.generate and TOTP.validate calls retain their historical two-digit, unpadded behavior and require no wire migration. New authentication flows should use generateSecure and validateSecure and store or transmit the six-character code as a string so leading zeroes are preserved. Ordinary valid BEEF, BRC-103 v0.1 peers, and public APIs remain compatible; malformed, ambiguous, oversized, identity-mismatched, or value-creating results now fail closed. Validated wallet results retain ordinary object behavior but are returned as owned value snapshots, so callers must not rely on object, array, or byte-buffer identity with the wallet adapter's raw response. Historical numeric-key JSON objects are recovered as bytes only for documented HTTP wallet byte fields; opaque numeric-key metadata remains an object. Deferred signableTransaction results may remain partial, and completed createAction results may use source values from the caller's immutable inputBEEF. Custom wallets must include direct source transactions for every other completed createAction or signAction input; duplicate input outpoints and unresolved or zero-input value-creating completed results are rejected. Browser applications that require DNS rebinding resistance must use a trusted egress proxy. +- Release note: Adds TOTP.generateSecure and TOTP.validateSecure for conventional six-digit zero-padded codes while retaining the published legacy methods, and hardens authenticated identity binding, replay state, transaction framing, BEEF ownership, registry payloads, transport deadlines, script-verifier registration, wallet-result ownership, certificate acquisition, and signing context. Corrects empty authenticated HTTP response bodies to use the BRC-104 -1 length sentinel, restoring verification of conforming bodyless responses. +- Migration: Existing TOTP.generate and TOTP.validate calls retain their historical two-digit, unpadded behavior and require no wire migration. New authentication flows should use generateSecure and validateSecure and store or transmit the six-character code as a string so leading zeroes are preserved. Ordinary valid BEEF, BRC-103 v0.1 peers, and public APIs remain compatible; malformed, ambiguous, oversized, identity-mismatched, or value-creating results now fail closed. Validated wallet results retain ordinary object behavior but are returned as owned value snapshots, so callers must not rely on object, array, or byte-buffer identity with the wallet adapter's raw response. Historical numeric-key JSON objects are recovered as bytes only for documented HTTP wallet byte fields; opaque numeric-key metadata remains an object. Deferred signableTransaction results may remain partial, and completed createAction results may use source values from the caller's immutable inputBEEF. Custom wallets must include direct source transactions for every other completed createAction or signAction input; duplicate input outpoints and unresolved or zero-input value-creating completed results are rejected. Browser applications that require DNS rebinding resistance must use a trusted egress proxy. The response-encoding correction is included in the existing unpublished 2.8.0 candidate. Conforming servers and non-empty response bytes require no migration; non-conforming servers signing zero for an empty response must use the BRC-104 -1 sentinel. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index aaa92c771..5778037e9 100644 --- a/docs/reference/stack-facts.md +++ b/docs/reference/stack-facts.md @@ -107,13 +107,13 @@ recorded container release route; they are not published by the public-package j | Metric | Current value | | --- | --- | | Vector files | 77 | -| Vectors | 6694 | -| Structurally passed | 6483 | +| Vectors | 6699 | +| Structurally passed | 6488 | | Governed skips | 211 | -| Required parity vectors | 6490 | +| Required parity vectors | 6495 | | Intended parity vectors | 204 | | Explicitly skipped vector entries | 7 | -| Corpus metadata revision | 2026-09-09 | +| Corpus metadata revision | 2026-09-23 | Structural runner pass/skip results and parity classifications answer different questions: the former is the current runner outcome, while the latter records cross-language diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index afc518636..1365427f3 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -168,8 +168,8 @@ "name": "@bsv/sdk", "publishedVersion": "2.7.1", "releaseType": "minor", - "summary": "Adds TOTP.generateSecure and TOTP.validateSecure for conventional six-digit zero-padded codes while retaining the published legacy methods, and hardens authenticated identity binding, replay state, transaction framing, BEEF ownership, registry payloads, transport deadlines, script-verifier registration, wallet-result ownership, certificate acquisition, and signing context.", - "migration": "Existing TOTP.generate and TOTP.validate calls retain their historical two-digit, unpadded behavior and require no wire migration. New authentication flows should use generateSecure and validateSecure and store or transmit the six-character code as a string so leading zeroes are preserved. Ordinary valid BEEF, BRC-103 v0.1 peers, and public APIs remain compatible; malformed, ambiguous, oversized, identity-mismatched, or value-creating results now fail closed. Validated wallet results retain ordinary object behavior but are returned as owned value snapshots, so callers must not rely on object, array, or byte-buffer identity with the wallet adapter's raw response. Historical numeric-key JSON objects are recovered as bytes only for documented HTTP wallet byte fields; opaque numeric-key metadata remains an object. Deferred signableTransaction results may remain partial, and completed createAction results may use source values from the caller's immutable inputBEEF. Custom wallets must include direct source transactions for every other completed createAction or signAction input; duplicate input outpoints and unresolved or zero-input value-creating completed results are rejected. Browser applications that require DNS rebinding resistance must use a trusted egress proxy." + "summary": "Adds TOTP.generateSecure and TOTP.validateSecure for conventional six-digit zero-padded codes while retaining the published legacy methods, and hardens authenticated identity binding, replay state, transaction framing, BEEF ownership, registry payloads, transport deadlines, script-verifier registration, wallet-result ownership, certificate acquisition, and signing context. Corrects empty authenticated HTTP response bodies to use the BRC-104 -1 length sentinel, restoring verification of conforming bodyless responses.", + "migration": "Existing TOTP.generate and TOTP.validate calls retain their historical two-digit, unpadded behavior and require no wire migration. New authentication flows should use generateSecure and validateSecure and store or transmit the six-character code as a string so leading zeroes are preserved. Ordinary valid BEEF, BRC-103 v0.1 peers, and public APIs remain compatible; malformed, ambiguous, oversized, identity-mismatched, or value-creating results now fail closed. Validated wallet results retain ordinary object behavior but are returned as owned value snapshots, so callers must not rely on object, array, or byte-buffer identity with the wallet adapter's raw response. Historical numeric-key JSON objects are recovered as bytes only for documented HTTP wallet byte fields; opaque numeric-key metadata remains an object. Deferred signableTransaction results may remain partial, and completed createAction results may use source values from the caller's immutable inputBEEF. Custom wallets must include direct source transactions for every other completed createAction or signAction input; duplicate input outpoints and unresolved or zero-input value-creating completed results are rejected. Browser applications that require DNS rebinding resistance must use a trusted egress proxy. The response-encoding correction is included in the existing unpublished 2.8.0 candidate. Conforming servers and non-empty response bytes require no migration; non-conforming servers signing zero for an empty response must use the BRC-104 -1 sentinel." }, { "name": "@bsv/simple", diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index 539d3fed2..e2b31321e 100644 --- a/governance/repository-health/baselines.json +++ b/governance/repository-health/baselines.json @@ -14,11 +14,11 @@ "run": "https://github.com/BSV-blockchain/ts-stack/actions/runs/30144812565" }, "conformance": { - "passed": 6483, + "passed": 6488, "skipped": 211, - "total": 6694, + "total": 6699, "vectorFiles": 77, - "run": "Local: pnpm --filter @bsv/conformance-runner-ts test --runInBand (2026-09-09); 6483 passed, 211 existing skips" + "run": "Local: pnpm --filter @bsv/conformance-runner-ts test (2026-09-23); 6488 vector cases passed plus 2 metadata/wire tests, 211 unchanged governed skips; adds 5 required BRC-104 response byte vectors" }, "testExceptions": { "explicitSkipDeclarations": 60, diff --git a/packages/middleware/auth-express-middleware/src/__tests/integration.test.ts b/packages/middleware/auth-express-middleware/src/__tests/integration.test.ts index 3a5714b81..23a087e03 100644 --- a/packages/middleware/auth-express-middleware/src/__tests/integration.test.ts +++ b/packages/middleware/auth-express-middleware/src/__tests/integration.test.ts @@ -58,6 +58,32 @@ describe('AuthFetch and AuthExpress Integration Tests', () => { // Main Tests // -------------------------------------------------------------------------- + test.each([204, 401, 403, 404])('verifies a signed bodyless HTTP %i response', async status => { + const authFetch = new AuthFetch(new MockWallet(privKey)) + const result = await authFetch.fetch(`${origin}/empty-${status}`) + expect(result.status).toBe(status) + expect(await result.text()).toBe('') + expect(result.headers.get('x-bsv-auth-identity-key')).toBeTruthy() + }) + + test('rejects a bodyless response whose signed HTTP status was changed in transit', async () => { + const tamper: typeof fetch = async (url, init) => { + const response = await fetch(url, init) + if (!String(url).endsWith('/empty-404')) return response + await response.arrayBuffer() + return new Response(null, { status: 204, headers: response.headers }) + } + const authFetch = new AuthFetch( + new MockWallet(privKey), + undefined, + undefined, + undefined, + {}, + tamper + ) + await expect(authFetch.fetch(`${origin}/empty-404`)).rejects.toThrow(/signature/i) + }) + test('Test 1: Simple POST request with JSON', async () => { const walletWithRequests = new MockWallet(privKey) const authFetch = new AuthFetch(walletWithRequests) diff --git a/packages/middleware/auth-express-middleware/src/__tests/testExpressServer.ts b/packages/middleware/auth-express-middleware/src/__tests/testExpressServer.ts index 5d382abe8..30000829d 100644 --- a/packages/middleware/auth-express-middleware/src/__tests/testExpressServer.ts +++ b/packages/middleware/auth-express-middleware/src/__tests/testExpressServer.ts @@ -129,6 +129,12 @@ export const startServer = (_port = 3000): Server => { // Add the mutual authentication middleware app.use(authMiddleware) + for (const status of [204, 401, 403, 404]) { + app.get(`/empty-${status}`, (_req: Request, res: Response) => { + res.status(status).end() + }) + } + app.get('/', (req: Request, res: Response) => { res.send('Hello, world!') }) diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index c4ec0b658..69173b2a5 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -216,6 +216,11 @@ All notable changes to this project will be documented in this file. The format ### 2.8.0 candidate — authenticated boundaries and additive secure TOTP APIs +- Correct empty authenticated HTTP response preimages to use the BRC-104 `-1` + length sentinel. Public transport byte vectors and real AuthFetch/Express + signature tests cover 204 and empty 401/403/404 responses and status tampering. + Non-empty responses and conforming servers require no migration. + - Add `TOTP.generateSecure()` and `TOTP.validateSecure()` for conventional six-digit, zero-padded codes while retaining the published two-digit, unpadded `generate()` and `validate()` behavior for wire compatibility. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 5c1fe7a3b..e9fcc5453 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -16,6 +16,11 @@ emits a portable `number[]` settlement artifact so HTTP, WebSocket, Message Box, and JSON transports preserve identical transaction bytes. The same boundary protects overlay lookup queries and JSON BEEF responses. +The unpublished 2.8.0 candidate verifies bodyless authenticated HTTP responses +using the BRC-104 `-1` body-length sentinel. Conforming 204 and empty error +responses now verify; non-empty response encoding is unchanged. Servers that +sign a zero body length for an empty response must adopt the specified sentinel. + AuthFetch stops pending certificate dispatch and session recovery after its request deadline. An already dispatched request may still complete on the server; callers must resolve its outcome before retrying a non-idempotent write. diff --git a/packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts b/packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts index c4bcd656d..86609a22b 100644 --- a/packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts +++ b/packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts @@ -310,11 +310,8 @@ export class SimplifiedFetchTransport implements Transport { writer.writeVarIntNum(valueBytes.length) writer.write(valueBytes) } - // BRC-104 §6.7.3/§6.9: an absent or empty body is encoded as -1, not as a - // zero length — the rule AuthFetch's writeRequestBody and - // writeOptionalText already apply to ABSENT values on the request side. - // Encoding 0 here made every signed bodyless response (a bare 404, 204 - // or empty 401/403) fail verification against a conforming counterparty. + // BRC-104 sections 6.7.3 and 6.9 use -1 for absent or empty response + // bodies. Zero would not reproduce a conforming server's signed preimage. if (body.length === 0) { writer.writeVarIntNum(-1) } else { diff --git a/packages/sdk/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts b/packages/sdk/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts index 3cef366c9..3ac22e291 100644 --- a/packages/sdk/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts +++ b/packages/sdk/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts @@ -654,9 +654,7 @@ describe('SimplifiedFetchTransport deserializeRequestPayload', () => { writer.write(encodedValue) } writer.writeVarIntNum(-1) - expect(() => transport.deserializeRequestPayload(writer.toArray())).toThrow( - 'duplicate header' - ) + expect(() => transport.deserializeRequestPayload(writer.toArray())).toThrow('duplicate header') }) }) @@ -955,61 +953,48 @@ describe('SimplifiedFetchTransport callback containment', () => { }) }) -describe('writeGeneralResponsePayload — BRC-104 body-length encoding', () => { - // BRC-104 §6.7.3: "If the body is empty, specify a length of -1 in the - // payload"; §6.9 lists the response preimage's final field as "Body length - // + body bytes (or -1 if none)". The request side (AuthFetch's - // writeRequestBody / writeOptionalText) already encodes -1; these pin the - // response side to the same rule. Asserting the exact value is the - // regression guard: under the pre-fix encoding the terminal varint read 0, - // so either test going green with that encoding is impossible. - const makeTransport = (): any => - new SimplifiedFetchTransport('https://api.example.com', jest.fn() as any) - - test('an empty body encodes as -1 with no trailing bytes', () => { - const response = new Response(null, { status: 404 }) - const payload: number[] = (makeTransport() as any).writeGeneralResponsePayload( - response, - [] - ) - const reader = new Utils.Reader(payload) - expect(reader.readVarIntNum()).toBe(404) // status - expect(reader.readVarIntNum()).toBe(0) // no signed headers - expect(reader.readVarIntNum()).toBe(-1) // empty body is -1, not 0 - expect(reader.pos).toBe(payload.length) // and nothing follows it - }) +describe('BRC-104 response body-length wire encoding', () => { + async function receive(response: Response): Promise { + const transport = new SimplifiedFetchTransport('https://api.example.com', async () => response) + const received: AuthMessage[] = [] + await transport.onData(async message => { + received.push(message) + }) + await transport.send(makeGeneralMessage()) + expect(received).toHaveLength(1) + return received[0].payload! + } - test('an empty body is still -1 when a request id precedes it', () => { - const requestIdBytes = Array.from({ length: 32 }, (_, i) => i) - const requestId = Utils.toBase64(requestIdBytes) - const response = new Response(null, { - status: 404, - headers: { 'x-bsv-auth-request-id': requestId } + function response(status: number, body: number[] = [], requestId?: string): Response { + return new Response(body.length > 0 ? new Uint8Array(body) : null, { + status, + headers: { + 'x-bsv-auth-version': '0.1', + 'x-bsv-auth-identity-key': 'server-key', + 'x-bsv-auth-signature': 'aabbcc', + ...(requestId === undefined ? {} : { 'x-bsv-auth-request-id': requestId }) + } }) - const payload: number[] = (makeTransport() as any).writeGeneralResponsePayload( - response, - [] - ) - const reader = new Utils.Reader(payload) - expect(reader.read(32)).toEqual(requestIdBytes) - expect(reader.readVarIntNum()).toBe(404) - expect(reader.readVarIntNum()).toBe(0) - expect(reader.readVarIntNum()).toBe(-1) - expect(reader.pos).toBe(payload.length) - }) - - test('a non-empty body still encodes its true length and bytes', () => { - const body = [1, 2, 3] - const response = new Response(null, { status: 404 }) - const payload: number[] = (makeTransport() as any).writeGeneralResponsePayload( - response, - body - ) - const reader = new Utils.Reader(payload) - expect(reader.readVarIntNum()).toBe(404) - expect(reader.readVarIntNum()).toBe(0) - expect(reader.readVarIntNum()).toBe(3) - expect(reader.read(3)).toEqual(body) - expect(reader.pos).toBe(payload.length) + } + + // Independent CompactSize byte vectors for BRC-104 sections 6.7.3 and 6.9. + // Exercise the public HTTP receive path, including its body reader and headers. + test.each([ + [204, [0xcc]], + [401, [0xfd, 0x91, 0x01]], + [403, [0xfd, 0x93, 0x01]], + [404, [0xfd, 0x94, 0x01]] + ])('bodyless HTTP %i encodes -1 with no trailing bytes', async (status, statusBytes) => { + const expected = [...statusBytes, 0, ...Array(9).fill(0xff)] + expect(await receive(response(status))).toEqual(expected) + const requestIdBytes = Array.from({ length: 32 }, (_, i) => i) + expect(await receive(response(status, [], Utils.toBase64(requestIdBytes)))).toEqual([ + ...requestIdBytes, + ...expected + ]) + }) + + test('a non-empty body retains its true length and exact bytes', async () => { + expect(await receive(response(404, [1, 2, 3]))).toEqual([0xfd, 0x94, 0x01, 0, 3, 1, 2, 3]) }) })