From da8c1be31584f2f61a2b568d5238102a076a04dd Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sat, 26 Sep 2026 19:10:09 -0700 Subject: [PATCH 1/2] fix(uhrp): authenticate bodyless CHIRP HEAD responses --- docs/reference/stack-facts.md | 4 +- infra/uhrp-server-basic/README.md | 4 + infra/uhrp-server-basic/package-lock.json | 4 +- infra/uhrp-server-basic/package.json | 2 +- infra/uhrp-server-basic/src/chirp/routes.ts | 10 ++- .../test/chirpHeadAuthentication.test.js | 73 +++++++++++++++++++ infra/uhrp-server-cloud-bucket/README.md | 5 ++ .../package-lock.json | 4 +- infra/uhrp-server-cloud-bucket/package.json | 2 +- .../src/chirp/routes.ts | 10 ++- .../__tests/chirpHeadAuthentication.test.js | 73 +++++++++++++++++++ 11 files changed, 179 insertions(+), 12 deletions(-) create mode 100644 infra/uhrp-server-basic/test/chirpHeadAuthentication.test.js create mode 100644 infra/uhrp-server-cloud-bucket/src/routes/__tests/chirpHeadAuthentication.test.js diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index a3792d08d..f92c946f8 100644 --- a/docs/reference/stack-facts.md +++ b/docs/reference/stack-facts.md @@ -82,8 +82,8 @@ the separately released and verified image digest. | BSV Chaintracks Server | `chaintracks-server` | `1.1.24` | `>=24 <25` | node, linux/amd64 | ghcr-keyless | [infra/chaintracks-server](https://github.com/bsv-blockchain/ts-stack/tree/main/infra/chaintracks-server) | | BSV Message Box Server | `@bsv/messagebox-server` | `1.1.47` | `>=24 <25` | node, linux/amd64 | ghcr-keyless | [infra/message-box-server](https://github.com/bsv-blockchain/ts-stack/tree/main/infra/message-box-server) | | BSV Overlay Server | `@bsv/overlay-express-examples` | `2.1.44` | `>=24 <25` | node, linux/amd64 | ghcr-keyless | [infra/overlay-server](https://github.com/bsv-blockchain/ts-stack/tree/main/infra/overlay-server) | -| BSV UHRP Basic Server | `@bsv/uhrp-lite` | `0.1.43` | `>=24 <25` | node, linux/amd64 | ghcr-keyless | [infra/uhrp-server-basic](https://github.com/bsv-blockchain/ts-stack/tree/main/infra/uhrp-server-basic) | -| BSV UHRP Cloud Bucket Server | `@bsv/uhrp-storage-server` | `0.2.46` | `>=24 <25` | node, linux/amd64 | ghcr-keyless | [infra/uhrp-server-cloud-bucket](https://github.com/bsv-blockchain/ts-stack/tree/main/infra/uhrp-server-cloud-bucket) | +| BSV UHRP Basic Server | `@bsv/uhrp-lite` | `0.1.44` | `>=24 <25` | node, linux/amd64 | ghcr-keyless | [infra/uhrp-server-basic](https://github.com/bsv-blockchain/ts-stack/tree/main/infra/uhrp-server-basic) | +| BSV UHRP Cloud Bucket Server | `@bsv/uhrp-storage-server` | `0.2.47` | `>=24 <25` | node, linux/amd64 | ghcr-keyless | [infra/uhrp-server-cloud-bucket](https://github.com/bsv-blockchain/ts-stack/tree/main/infra/uhrp-server-cloud-bucket) | | Wallet Authentication Backend | `@bsv/wab-server` | `1.8.7` | `>=24 <25` | node, linux/amd64 | ghcr-and-aws-marketplace-keyless | [infra/wab](https://github.com/bsv-blockchain/ts-stack/tree/main/infra/wab) | | BSV Wallet Infrastructure | `@bsv/wallet-infra` | `2.0.46` | `>=24 <25` | node, linux/amd64 | ghcr-keyless | [infra/wallet-infra](https://github.com/bsv-blockchain/ts-stack/tree/main/infra/wallet-infra) | diff --git a/infra/uhrp-server-basic/README.md b/infra/uhrp-server-basic/README.md index 09dbfa942..422bfb0a9 100644 --- a/infra/uhrp-server-basic/README.md +++ b/infra/uhrp-server-basic/README.md @@ -72,6 +72,10 @@ upload cannot execute with the API origin's browser authority. ## CHIRP complete-host support +Authenticated staged-object HEAD responses have no body, including existence +and validation-error responses. Authentication signs the same empty bytes +that HTTP sends. Other methods retain their existing JSON error responses. + The server also implements the BRC-167 baseline upload-session and complete- host routes under `/chirp/v1`. Objects are stream-hashed into a deduplicated filesystem store, a root is advertised through ordinary `tm_uhrp` only after diff --git a/infra/uhrp-server-basic/package-lock.json b/infra/uhrp-server-basic/package-lock.json index 5cfbd41c7..0a5e51941 100644 --- a/infra/uhrp-server-basic/package-lock.json +++ b/infra/uhrp-server-basic/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bsv/uhrp-lite", - "version": "0.1.43", + "version": "0.1.44", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bsv/uhrp-lite", - "version": "0.1.43", + "version": "0.1.44", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@bsv/auth-express-middleware": "^2.2.8", diff --git a/infra/uhrp-server-basic/package.json b/infra/uhrp-server-basic/package.json index 6612fe0f0..d651f1080 100644 --- a/infra/uhrp-server-basic/package.json +++ b/infra/uhrp-server-basic/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/uhrp-lite", - "version": "0.1.43", + "version": "0.1.44", "overrides": { "brace-expansion": "5.0.9", "gaxios": "7.3.0" diff --git a/infra/uhrp-server-basic/src/chirp/routes.ts b/infra/uhrp-server-basic/src/chirp/routes.ts index 18953067d..6c03b9aaa 100644 --- a/infra/uhrp-server-basic/src/chirp/routes.ts +++ b/infra/uhrp-server-basic/src/chirp/routes.ts @@ -94,7 +94,11 @@ async function createSessionHandler(req: AuthenticatedRequest, res: Response): P return error(res, 400, 'ERR_CHIRP_SESSION', 'Invalid CHIRP retentionSeconds or logicalLength.') } try { - const session = await getChirpStore().createSession(identityKey, retentionSeconds, logicalLength) + const session = await getChirpStore().createSession( + identityKey, + retentionSeconds, + logicalLength + ) return res.status(201).json({ uploadId: session.uploadId, stagingExpiresAt: String(session.stagingExpiresAt) @@ -121,7 +125,8 @@ async function headStagedObjectHandler( if (uploadId == null || identifier == null) return error(res, 400, 'ERR_CHIRP_IDENTIFIER', 'Invalid upload or object identifier.') const exists = await getChirpStore().hasStagedObject(uploadId, identityKey, identifier) - return exists ? res.sendStatus(200) : res.sendStatus(404) + // HEAD omits a body on the wire; authentication must sign those same bytes. + return res.status(exists ? 200 : 404).end() } async function putStagedObjectHandler(req: AuthenticatedRequest, res: Response): Promise { @@ -370,6 +375,7 @@ function authError(res: Response): Response { } function error(res: Response, status: number, code: string, description: string): Response { + if (res.req?.method === 'HEAD') return res.status(status).end() return res.status(status).json({ status: 'error', code, description }) } diff --git a/infra/uhrp-server-basic/test/chirpHeadAuthentication.test.js b/infra/uhrp-server-basic/test/chirpHeadAuthentication.test.js new file mode 100644 index 000000000..a3a581362 --- /dev/null +++ b/infra/uhrp-server-basic/test/chirpHeadAuthentication.test.js @@ -0,0 +1,73 @@ +process.env.BSV_NETWORK = 'testnet' +process.env.HOSTING_DOMAIN = 'storage.example.com' +process.env.NODE_ENV = 'test' +process.env.WALLET_STORAGE_URL = 'http://localhost:3000' + +const mockHasStagedObject = jest.fn() +jest.mock('../out/src/chirp/store', () => ({ + getChirpStore: () => ({ hasStagedObject: mockHasStagedObject }) +})) +jest.mock('../out/src/utils/createUHRPAdvertisement', () => ({ + createUHRPAdvertisementWithResult: jest.fn() +})) +jest.mock('../out/src/logger', () => ({ log: { error: jest.fn() } })) + +const express = require('express') +const { createAuthMiddleware } = require('@bsv/auth-express-middleware') +const { AuthFetch, PrivateKey, ProtoWallet } = require('@bsv/sdk') +const { chirpPostAuthRoutes } = require('../out/src/chirp/routes') +const { objectIdentifierForBytes } = require('../out/src/chirp/core/hash') + +const present = objectIdentifierForBytes(Uint8Array.of(1)) +const absent = objectIdentifierForBytes(Uint8Array.of(2)) +let server +let origin +let clientWallet + +beforeAll(async () => { + const app = express() + const serverWallet = new ProtoWallet(PrivateKey.fromRandom()) + clientWallet = new ProtoWallet(PrivateKey.fromRandom()) + app.use(express.json()) + app.use(createAuthMiddleware({ wallet: serverWallet, allowUnauthenticated: false })) + const route = chirpPostAuthRoutes.find( + value => value.type === 'head' && value.path.includes('/uploads/') + ) + app.head(route.path, route.func) + server = await new Promise(resolve => { + const listener = app.listen(0, '127.0.0.1', () => resolve(listener)) + }) + origin = `http://127.0.0.1:${server.address().port}` +}) + +beforeEach(() => { + mockHasStagedObject.mockImplementation(async (_uploadId, identityKey, identifier) => { + expect(identityKey).toBe((await clientWallet.getPublicKey({ identityKey: true })).publicKey) + return identifier === present + }) +}) + +afterAll(async () => { + server.closeAllConnections() + await new Promise(resolve => server.close(resolve)) +}) + +test.each([ + ['present staged object', present, 200], + ['absent staged object', absent, 404], + ['invalid object identifier', 'invalid-object', 400] +])( + 'authenticates a bodyless HEAD for %s', + async (_name, identifier, status) => { + const auth = new AuthFetch(clientWallet, undefined, undefined, undefined, {}, fetch) + const response = await auth.fetch( + `${origin}/chirp/v1/uploads/test-session/objects/${identifier}`, + { + method: 'HEAD' + } + ) + expect(response.status).toBe(status) + expect((await response.arrayBuffer()).byteLength).toBe(0) + }, + 15_000 +) diff --git a/infra/uhrp-server-cloud-bucket/README.md b/infra/uhrp-server-cloud-bucket/README.md index a43507cb2..bfed4cf68 100644 --- a/infra/uhrp-server-cloud-bucket/README.md +++ b/infra/uhrp-server-cloud-bucket/README.md @@ -32,6 +32,11 @@ strings and credentials are rejected. Renewal reads the actual CHIRP root object, extends its committed closure and rejects an inactive root. It does not create or rely on a duplicate CDN copy. +Authenticated staged-object HEAD responses have no body, including existence +and validation-error responses. Authentication signs the same empty bytes +that HTTP sends, allowing clients to verify both present and absent objects +before an upload. Other methods retain their existing JSON error responses. + ## Advertisement, ownership, and upload trust The public UHRP token authenticates the host identity, content hash, HTTPS diff --git a/infra/uhrp-server-cloud-bucket/package-lock.json b/infra/uhrp-server-cloud-bucket/package-lock.json index e6b7679e1..28541e1b2 100644 --- a/infra/uhrp-server-cloud-bucket/package-lock.json +++ b/infra/uhrp-server-cloud-bucket/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bsv/uhrp-storage-server", - "version": "0.2.46", + "version": "0.2.47", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bsv/uhrp-storage-server", - "version": "0.2.46", + "version": "0.2.47", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@bsv/auth-express-middleware": "^2.2.8", diff --git a/infra/uhrp-server-cloud-bucket/package.json b/infra/uhrp-server-cloud-bucket/package.json index 91c6a9c70..5a9077de1 100644 --- a/infra/uhrp-server-cloud-bucket/package.json +++ b/infra/uhrp-server-cloud-bucket/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/uhrp-storage-server", - "version": "0.2.46", + "version": "0.2.47", "overrides": { "brace-expansion": "5.0.9", "gaxios": "7.3.0", diff --git a/infra/uhrp-server-cloud-bucket/src/chirp/routes.ts b/infra/uhrp-server-cloud-bucket/src/chirp/routes.ts index 18953067d..6c03b9aaa 100644 --- a/infra/uhrp-server-cloud-bucket/src/chirp/routes.ts +++ b/infra/uhrp-server-cloud-bucket/src/chirp/routes.ts @@ -94,7 +94,11 @@ async function createSessionHandler(req: AuthenticatedRequest, res: Response): P return error(res, 400, 'ERR_CHIRP_SESSION', 'Invalid CHIRP retentionSeconds or logicalLength.') } try { - const session = await getChirpStore().createSession(identityKey, retentionSeconds, logicalLength) + const session = await getChirpStore().createSession( + identityKey, + retentionSeconds, + logicalLength + ) return res.status(201).json({ uploadId: session.uploadId, stagingExpiresAt: String(session.stagingExpiresAt) @@ -121,7 +125,8 @@ async function headStagedObjectHandler( if (uploadId == null || identifier == null) return error(res, 400, 'ERR_CHIRP_IDENTIFIER', 'Invalid upload or object identifier.') const exists = await getChirpStore().hasStagedObject(uploadId, identityKey, identifier) - return exists ? res.sendStatus(200) : res.sendStatus(404) + // HEAD omits a body on the wire; authentication must sign those same bytes. + return res.status(exists ? 200 : 404).end() } async function putStagedObjectHandler(req: AuthenticatedRequest, res: Response): Promise { @@ -370,6 +375,7 @@ function authError(res: Response): Response { } function error(res: Response, status: number, code: string, description: string): Response { + if (res.req?.method === 'HEAD') return res.status(status).end() return res.status(status).json({ status: 'error', code, description }) } diff --git a/infra/uhrp-server-cloud-bucket/src/routes/__tests/chirpHeadAuthentication.test.js b/infra/uhrp-server-cloud-bucket/src/routes/__tests/chirpHeadAuthentication.test.js new file mode 100644 index 000000000..c25c1820e --- /dev/null +++ b/infra/uhrp-server-cloud-bucket/src/routes/__tests/chirpHeadAuthentication.test.js @@ -0,0 +1,73 @@ +process.env.BSV_NETWORK = 'testnet' +process.env.HOSTING_DOMAIN = 'storage.example.com' +process.env.NODE_ENV = 'test' +process.env.WALLET_STORAGE_URL = 'http://localhost:3000' + +const mockHasStagedObject = jest.fn() +jest.mock('../../chirp/store', () => ({ + getChirpStore: () => ({ hasStagedObject: mockHasStagedObject }) +})) +jest.mock('../../utils/createUHRPAdvertisement', () => ({ + createUHRPAdvertisementWithResult: jest.fn() +})) +jest.mock('../../logger', () => ({ log: { error: jest.fn() } })) + +const express = require('express') +const { createAuthMiddleware } = require('@bsv/auth-express-middleware') +const { AuthFetch, PrivateKey, ProtoWallet } = require('@bsv/sdk') +const { chirpPostAuthRoutes } = require('../../chirp/routes') +const { objectIdentifierForBytes } = require('../../chirp/core/hash') + +const present = objectIdentifierForBytes(Uint8Array.of(1)) +const absent = objectIdentifierForBytes(Uint8Array.of(2)) +let server +let origin +let clientWallet + +beforeAll(async () => { + const app = express() + const serverWallet = new ProtoWallet(PrivateKey.fromRandom()) + clientWallet = new ProtoWallet(PrivateKey.fromRandom()) + app.use(express.json()) + app.use(createAuthMiddleware({ wallet: serverWallet, allowUnauthenticated: false })) + const route = chirpPostAuthRoutes.find( + value => value.type === 'head' && value.path.includes('/uploads/') + ) + app.head(route.path, route.func) + server = await new Promise(resolve => { + const listener = app.listen(0, '127.0.0.1', () => resolve(listener)) + }) + origin = `http://127.0.0.1:${server.address().port}` +}) + +beforeEach(() => { + mockHasStagedObject.mockImplementation(async (_uploadId, identityKey, identifier) => { + expect(identityKey).toBe((await clientWallet.getPublicKey({ identityKey: true })).publicKey) + return identifier === present + }) +}) + +afterAll(async () => { + server.closeAllConnections() + await new Promise(resolve => server.close(resolve)) +}) + +test.each([ + ['present staged object', present, 200], + ['absent staged object', absent, 404], + ['invalid object identifier', 'invalid-object', 400] +])( + 'authenticates a bodyless HEAD for %s', + async (_name, identifier, status) => { + const auth = new AuthFetch(clientWallet, undefined, undefined, undefined, {}, fetch) + const response = await auth.fetch( + `${origin}/chirp/v1/uploads/test-session/objects/${identifier}`, + { + method: 'HEAD' + } + ) + expect(response.status).toBe(status) + expect((await response.arrayBuffer()).byteLength).toBe(0) + }, + 15_000 +) From e0ad6d9e88fbe15e71092c2c88370f3f5d656056 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Sat, 26 Sep 2026 19:16:38 -0700 Subject: [PATCH 2/2] test(uhrp): use service rate limits in authenticated HEAD fixture --- .../test/chirpHeadAuthentication.test.js | 15 +++++++++++++++ .../__tests/chirpHeadAuthentication.test.js | 12 ++++++++++++ 2 files changed, 27 insertions(+) diff --git a/infra/uhrp-server-basic/test/chirpHeadAuthentication.test.js b/infra/uhrp-server-basic/test/chirpHeadAuthentication.test.js index a3a581362..cf2dd6563 100644 --- a/infra/uhrp-server-basic/test/chirpHeadAuthentication.test.js +++ b/infra/uhrp-server-basic/test/chirpHeadAuthentication.test.js @@ -14,6 +14,11 @@ jest.mock('../out/src/logger', () => ({ log: { error: jest.fn() } })) const express = require('express') const { createAuthMiddleware } = require('@bsv/auth-express-middleware') +const { rateLimit } = require('express-rate-limit') +const { + rateLimitOptions, + authenticatedIdentityKey +} = require('../out/src/security/rateLimitPolicy') const { AuthFetch, PrivateKey, ProtoWallet } = require('@bsv/sdk') const { chirpPostAuthRoutes } = require('../out/src/chirp/routes') const { objectIdentifierForBytes } = require('../out/src/chirp/core/hash') @@ -29,7 +34,17 @@ beforeAll(async () => { const serverWallet = new ProtoWallet(PrivateKey.fromRandom()) clientWallet = new ProtoWallet(PrivateKey.fromRandom()) app.use(express.json()) + app.use(rateLimit(rateLimitOptions('UHRP_PRE_AUTH_RATE_LIMIT', { windowMs: 60_000, limit: 300 }))) app.use(createAuthMiddleware({ wallet: serverWallet, allowUnauthenticated: false })) + app.use( + rateLimit( + rateLimitOptions( + 'UHRP_AUTHENTICATED_RATE_LIMIT', + { windowMs: 60_000, limit: 1000 }, + { keyGenerator: authenticatedIdentityKey } + ) + ) + ) const route = chirpPostAuthRoutes.find( value => value.type === 'head' && value.path.includes('/uploads/') ) diff --git a/infra/uhrp-server-cloud-bucket/src/routes/__tests/chirpHeadAuthentication.test.js b/infra/uhrp-server-cloud-bucket/src/routes/__tests/chirpHeadAuthentication.test.js index c25c1820e..72974f05f 100644 --- a/infra/uhrp-server-cloud-bucket/src/routes/__tests/chirpHeadAuthentication.test.js +++ b/infra/uhrp-server-cloud-bucket/src/routes/__tests/chirpHeadAuthentication.test.js @@ -14,6 +14,8 @@ jest.mock('../../logger', () => ({ log: { error: jest.fn() } })) const express = require('express') const { createAuthMiddleware } = require('@bsv/auth-express-middleware') +const { rateLimit } = require('express-rate-limit') +const { rateLimitOptions, authenticatedIdentityKey } = require('../../security/rateLimitPolicy') const { AuthFetch, PrivateKey, ProtoWallet } = require('@bsv/sdk') const { chirpPostAuthRoutes } = require('../../chirp/routes') const { objectIdentifierForBytes } = require('../../chirp/core/hash') @@ -29,7 +31,17 @@ beforeAll(async () => { const serverWallet = new ProtoWallet(PrivateKey.fromRandom()) clientWallet = new ProtoWallet(PrivateKey.fromRandom()) app.use(express.json()) + app.use(rateLimit(rateLimitOptions('UHRP_PRE_AUTH_RATE_LIMIT', { windowMs: 60_000, limit: 300 }))) app.use(createAuthMiddleware({ wallet: serverWallet, allowUnauthenticated: false })) + app.use( + rateLimit( + rateLimitOptions( + 'UHRP_AUTHENTICATED_RATE_LIMIT', + { windowMs: 60_000, limit: 1000 }, + { keyGenerator: authenticatedIdentityKey } + ) + ) + ) const route = chirpPostAuthRoutes.find( value => value.type === 'head' && value.path.includes('/uploads/') )