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 docs/reference/stack-facts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down
4 changes: 4 additions & 0 deletions infra/uhrp-server-basic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions infra/uhrp-server-basic/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion infra/uhrp-server-basic/package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
10 changes: 8 additions & 2 deletions infra/uhrp-server-basic/src/chirp/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<Response> {
Expand Down Expand Up @@ -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 })
}

Expand Down
88 changes: 88 additions & 0 deletions infra/uhrp-server-basic/test/chirpHeadAuthentication.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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 { 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')

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(rateLimit(rateLimitOptions('UHRP_PRE_AUTH_RATE_LIMIT', { windowMs: 60_000, limit: 300 })))
app.use(createAuthMiddleware({ wallet: serverWallet, allowUnauthenticated: false }))
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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/')
)
app.head(route.path, route.func)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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
)
5 changes: 5 additions & 0 deletions infra/uhrp-server-cloud-bucket/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions infra/uhrp-server-cloud-bucket/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion infra/uhrp-server-cloud-bucket/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 8 additions & 2 deletions infra/uhrp-server-cloud-bucket/src/chirp/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<Response> {
Expand Down Expand Up @@ -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 })
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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 { 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')

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(rateLimit(rateLimitOptions('UHRP_PRE_AUTH_RATE_LIMIT', { windowMs: 60_000, limit: 300 })))
app.use(createAuthMiddleware({ wallet: serverWallet, allowUnauthenticated: false }))
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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/')
)
app.head(route.path, route.func)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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
)
Loading