Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions conformance/META.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 6 additions & 6 deletions conformance/PARITY_MATRIX.json
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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",
Expand Down
48 changes: 47 additions & 1 deletion conformance/runner/ts/dispatchers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import { expect } from '@jest/globals'
import { SimplifiedFetchTransport, Utils } from '@bsv/sdk'

export const categories: ReadonlyArray<string> = ['brc31-handshake']

Expand Down Expand Up @@ -368,10 +369,55 @@ export function dispatch(
throw new Error(`auth dispatcher: unknown category '${category}'`)
}

async function dispatchResponsePreimage(
input: Record<string, unknown>,
expected: Record<string, unknown>
): Promise<void> {
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<string, unknown>,
expected: Record<string, unknown>
): void {
): void | Promise<void> {
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')
Expand Down
72 changes: 71 additions & 1 deletion conformance/vectors/auth/brc31-handshake.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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"]
}
]
}
6 changes: 3 additions & 3 deletions docs/reference/package-api-migrations.md

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions docs/reference/stack-facts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions governance/package-release-notes.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions governance/repository-health/baselines.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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!')
})
Expand Down
5 changes: 5 additions & 0 deletions packages/sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions packages/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,8 +310,14 @@ 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 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 {
writer.writeVarIntNum(body.length)
writer.write(body)
}
return writer.toArray()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})

Expand Down Expand Up @@ -954,3 +952,49 @@ describe('SimplifiedFetchTransport callback containment', () => {
)
})
})

describe('BRC-104 response body-length wire encoding', () => {
async function receive(response: Response): Promise<number[]> {
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!
}

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 })
}
})
}

// 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<number>(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])
})
})
Loading
Loading