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
7 changes: 7 additions & 0 deletions infra/uhrp-server-cloud-bucket/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ startup.

UHRP and CHIRP renewal keep the existing retention timestamp or advance it to the renewed expiry plus five minutes. Metadata updates use metageneration preconditions and bounded retries; a short extension never reduces Google Cloud Storage `customTime`.

Signed ownership metadata accepts the ordinary `/cdn/<object>` location and the
exact CHIRP root route `/chirp/v1/<root>/objects/<root>`. A CHIRP root identifier
must equal the advertised content hash identifier; unrelated objects, query
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.

## Advertisement, ownership, and upload trust

The public UHRP token authenticates the host identity, content hash, HTTPS
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
process.env.SERVER_PRIVATE_KEY = '44'.repeat(32)
process.env.BSV_NETWORK = 'testnet'
process.env.GCP_BUCKET_NAME = 'synthetic-bucket'

const {
PrivateKey,
ProtoWallet,
PushDrop,
SHIPBroadcaster,
StorageUtils,
Utils
} = require('@bsv/sdk')
const mockWallet = new ProtoWallet(new PrivateKey(process.env.SERVER_PRIVATE_KEY, 'hex'))
const mockFile = { getMetadata: jest.fn(), setMetadata: jest.fn() }
const mockBucket = { file: jest.fn(() => mockFile) }
const mockExtendRootLease = jest.fn()
const mockListAdvertisements = jest.fn()

jest.mock('../../utils/googleCloudStorage', () => ({
createGoogleCloudStorage: () => ({ bucket: () => mockBucket })
}))
jest.mock('../../utils/walletSingleton', () => ({ getWallet: async () => mockWallet }))
jest.mock('../../utils/storedAdvertisements', () => ({
listVerifiedAdvertisements: mockListAdvertisements
}))
jest.mock('../../utils/getPriceForFile', () => ({ __esModule: true, default: async () => 1 }))
jest.mock('../../chirp/store', () => ({
getChirpStore: () => ({ extendRootLease: mockExtendRootLease })
}))
jest.mock('../../logger', () => ({ log: { error: jest.fn() } }))
jest.mock('../../utils/completeUhrpAction', () => ({
completeUhrpAction: async (_wallet, args) => {
const { Script, Transaction } = require('@bsv/sdk')
const tx = new Transaction()
for (const output of args.outputs)
tx.addOutput({
satoshis: output.satoshis,
lockingScript: Script.fromHex(output.lockingScript)
})
return tx
}
}))

const { createAdvertisementMetadata } = require('../../utils/advertisementMetadata')
const renew = require('../renew').default.func
const hash = Array.from({ length: 32 }, (_, index) => index)
const identifier = StorageUtils.getURLForHash(hash).replace(/^uhrp:\/\//, '')
const identityKey = new PrivateKey('45'.repeat(32), 'hex').toPublicKey().toString()
const expiry = 2_000_000_000

function response() {
const res = {}
res.status = jest.fn(() => res)
res.json = jest.fn(() => res)
return res
}

async function advertisement(chirpRoot) {
const hostedFileLocation = chirpRoot
? `https://storage.example/chirp/v1/${identifier}/objects/${identifier}`
: `https://storage.example/cdn/${identifier}`
const lockingScript = await new PushDrop(mockWallet).lock(
[
Utils.toArray(
new PrivateKey(process.env.SERVER_PRIVATE_KEY, 'hex').toPublicKey().toString(),
'hex'
),
hash,
Utils.toArray(hostedFileLocation, 'utf8'),
new Utils.Writer().writeVarIntNum(expiry).toArray(),
new Utils.Writer().writeVarIntNum(100).toArray()
],
[2, 'uhrp advertisement'],
'1',
'anyone',
true
)
const { metadata } = createAdvertisementMetadata({
objectIdentifier: identifier,
uploaderIdentityKey: identityKey,
hostedFileLocation,
hash,
expiryTime: expiry,
fileSize: 100,
contentType: 'application/vnd.bsv.chirp-node'
})
mockListAdvertisements.mockResolvedValue({
advertisements: [{ metadata, lockingScript, outpoint: '66'.repeat(32) + '.0' }],
BEEF: {}
})
}

beforeEach(async () => {
jest.clearAllMocks()
jest.restoreAllMocks()
await advertisement(false)
mockFile.getMetadata.mockResolvedValue([
{ size: '100', customTime: new Date((expiry + 300) * 1000).toISOString(), metageneration: '7' }
])
mockFile.setMetadata.mockResolvedValue([])
mockExtendRootLease.mockResolvedValue(true)
jest
.spyOn(SHIPBroadcaster.prototype, 'broadcast')
.mockImplementation(async tx => ({ status: 'success', txid: tx.id('hex') }))
})

afterEach(() => jest.restoreAllMocks())

test.each([true, false])('renews the actual storage object for CHIRP root %p', async chirpRoot => {
await advertisement(chirpRoot)
const res = response()
await renew(
{
auth: { identityKey },
body: { uhrpUrl: StorageUtils.getURLForHash(hash), additionalMinutes: 1 }
},
res
)
expect(mockBucket.file).toHaveBeenCalledWith(
chirpRoot ? `chirp/v1/objects/${identifier}` : `cdn/${identifier}`
)
if (chirpRoot) {
expect(mockExtendRootLease).toHaveBeenCalledWith(identifier, expiry + 60)
expect(mockFile.setMetadata).not.toHaveBeenCalled()
} else {
expect(mockExtendRootLease).not.toHaveBeenCalled()
expect(mockFile.setMetadata).toHaveBeenCalledWith(
{ customTime: new Date((expiry + 360) * 1000).toISOString() },
{ ifMetagenerationMatch: '7' }
)
}
expect(res.status).toHaveBeenCalledWith(200)
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ status: 'success', newExpiryTime: expiry + 60 })
)
})

test('does not acknowledge renewal when ordinary object retention fails', async () => {
mockFile.setMetadata.mockRejectedValue(new Error('Synthetic storage failure'))
const res = response()
await renew(
{
auth: { identityKey },
body: { uhrpUrl: StorageUtils.getURLForHash(hash), additionalMinutes: 1 }
},
res
)
expect(res.status).toHaveBeenCalledWith(500)
expect(SHIPBroadcaster.prototype.broadcast).not.toHaveBeenCalled()
})

test('does not acknowledge renewal or use a CDN fallback after a closure failure', async () => {
await advertisement(true)
mockExtendRootLease.mockRejectedValue(new Error('Synthetic closure failure'))
const res = response()
await renew(
{
auth: { identityKey },
body: { uhrpUrl: StorageUtils.getURLForHash(hash), additionalMinutes: 1 }
},
res
)
expect(res.status).toHaveBeenCalledWith(500)
expect(mockFile.setMetadata).not.toHaveBeenCalled()
expect(SHIPBroadcaster.prototype.broadcast).not.toHaveBeenCalled()
})

test('does not acknowledge renewal for an inactive CHIRP root', async () => {
await advertisement(true)
mockExtendRootLease.mockResolvedValue(false)
const res = response()
await renew(
{
auth: { identityKey },
body: { uhrpUrl: StorageUtils.getURLForHash(hash), additionalMinutes: 1 }
},
res
)
expect(res.status).toHaveBeenCalledWith(500)
expect(mockFile.setMetadata).not.toHaveBeenCalled()
expect(SHIPBroadcaster.prototype.broadcast).not.toHaveBeenCalled()
})
15 changes: 12 additions & 3 deletions infra/uhrp-server-cloud-bucket/src/routes/renew.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,12 @@ const renewHandler = async (req: RenewRequest, res: Response<RenewResponse>) =>
if (Date.now() > previous.metadata.expiryTime * 1000) {
return res.status(410).json({ status: 'error', code: 'ERR_ADVERTISEMENT_EXPIRED', description: `The advertisement for ${uhrpUrl} has expired` })
}
const objectFile = storage.bucket(GCP_BUCKET_NAME).file(`cdn/${previous.metadata.objectIdentifier}`)
const identifier = previous.metadata.objectIdentifier
const isChirpRoot = new URL(previous.metadata.hostedFileLocation).pathname ===
`/chirp/v1/${identifier}/objects/${identifier}`
const objectFile = storage.bucket(GCP_BUCKET_NAME).file(
isChirpRoot ? `chirp/v1/objects/${identifier}` : `cdn/${identifier}`
)
const [gcsMetadata] = await objectFile.getMetadata()
const gcsSize = typeof gcsMetadata.size === 'string' ? Number(gcsMetadata.size) : gcsMetadata.size
if (!Number.isSafeInteger(gcsSize) || gcsSize !== previous.metadata.fileSize) {
Expand Down Expand Up @@ -144,8 +149,12 @@ const renewHandler = async (req: RenewRequest, res: Response<RenewResponse>) =>
output.satoshis === 1 && output.lockingScript.toHex() === newLockingScript.toHex()
).length !== 1) throw new Error('Wallet did not create exactly one renewed UHRP advertisement')

const chirpExtended = await getChirpStore().extendRootLease(previous.metadata.objectIdentifier, newExpiryTime)
if (!chirpExtended) await extendObjectRetention(objectFile, newExpiryTime)
if (isChirpRoot) {
const extended = await getChirpStore().extendRootLease(identifier, newExpiryTime)
if (!extended) throw new Error('CHIRP root lease is not active')
} else {
await extendObjectRetention(objectFile, newExpiryTime)
}
const result = await new SHIPBroadcaster(['tm_uhrp'], {
networkPreset: lookupPreset as 'mainnet' | 'testnet'
}).broadcast(transaction)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
process.env.SERVER_PRIVATE_KEY = '55'.repeat(32)

const {
PrivateKey,
ProtoWallet,
PushDrop,
Utils
} = require('@bsv/sdk')
const { PrivateKey, ProtoWallet, PushDrop, StorageUtils, Utils } = require('@bsv/sdk')
const {
advertisementTags,
createAdvertisementMetadata,
Expand All @@ -19,13 +14,15 @@ const hash = Array.from({ length: 32 }, (_, index) => index)
const objectIdentifier = '3mJr7AoUXx2Wqd'
const hostedFileLocation = `https://files.example/cdn/${objectIdentifier}`

async function fixture() {
async function fixture(options = {}) {
const selectedObject = options.objectIdentifier ?? objectIdentifier
const selectedLocation = options.hostedFileLocation ?? hostedFileLocation
const identity = await serverWallet.getPublicKey({ identityKey: true })
const script = await new PushDrop(serverWallet).lock(
[
Utils.toArray(identity.publicKey, 'hex'),
hash,
Utils.toArray(hostedFileLocation, 'utf8'),
Utils.toArray(selectedLocation, 'utf8'),
new Utils.Writer().writeVarIntNum(2_000_000_000).toArray(),
new Utils.Writer().writeVarIntNum(100).toArray()
],
Expand All @@ -35,9 +32,9 @@ async function fixture() {
true
)
const signed = createAdvertisementMetadata({
objectIdentifier,
objectIdentifier: selectedObject,
uploaderIdentityKey,
hostedFileLocation,
hostedFileLocation: selectedLocation,
hash,
expiryTime: 2_000_000_000,
fileSize: 100,
Expand All @@ -51,12 +48,16 @@ test('accepts server-signed ownership metadata only when it matches the token',
await expect(verifyAdvertisementMetadata(signed.customInstructions, script)).resolves.toEqual(
signed.metadata
)
expect(() => requireAdvertisementTags(advertisementTags(signed.metadata), signed.metadata)).not.toThrow()
expect(() =>
requireAdvertisementTags(advertisementTags(signed.metadata), signed.metadata)
).not.toThrow()
})

test('rejects unsigned legacy metadata and edited owner fields', async () => {
const { script, signed } = await fixture()
await expect(verifyAdvertisementMetadata(undefined, script)).rejects.toThrow('metadata is missing')
await expect(verifyAdvertisementMetadata(undefined, script)).rejects.toThrow(
'metadata is missing'
)

const envelope = JSON.parse(signed.customInstructions)
envelope.metadata.uploaderIdentityKey = PrivateKey.fromRandom().toPublicKey().toString()
Expand All @@ -74,3 +75,38 @@ test('rejects relabeled wallet tags after signature verification', async () => {
)
expect(() => requireAdvertisementTags(tags, signed.metadata)).toThrow('do not match')
})

test('binds signed CHIRP root ownership to its exact root path and hash identifier', async () => {
const root = StorageUtils.getURLForHash(hash)
const { script, signed } = await fixture({
objectIdentifier: root,
hostedFileLocation: `https://files.example/chirp/v1/${root}/objects/${root}`
})
await expect(verifyAdvertisementMetadata(signed.customInstructions, script)).resolves.toEqual(
signed.metadata
)
expect(() =>
requireAdvertisementTags(advertisementTags(signed.metadata), signed.metadata)
).not.toThrow()
})

test.each([
root => `https://files.example/chirp/v1/${root}/objects/3mJr7AoUXx2Wqd`,
root => `https://files.example/chirp/v1/3mJr7AoUXx2Wqd/objects/${root}`,
root => `https://files.example/chirp/v1/${root}/objects/${root}?download=1`,
root => `https://files.example/chirp/v1/${root}/objects/${root}#fragment`,
root => `https://user:password@files.example/chirp/v1/${root}/objects/${root}`
])('rejects a CHIRP location that is not the exact credential-free root route', async location => {
const root = StorageUtils.getURLForHash(hash)
await expect(
fixture({ objectIdentifier: root, hostedFileLocation: location(root) })
).rejects.toThrow('file location is invalid')
})

test('rejects a CHIRP route whose root does not identify the advertised hash', async () => {
await expect(
fixture({
hostedFileLocation: `https://files.example/chirp/v1/${objectIdentifier}/objects/${objectIdentifier}`
})
).rejects.toThrow('file location is invalid')
})
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,20 @@ const mockWallet = {
verifySignature: async args => await cryptoWallet.verifySignature(args),
createAction: jest.fn(async args => {
partial = new Transaction()
partial.addInput({ sourceTXID: '66'.repeat(32), sourceOutputIndex: 0, unlockingScript: Script.fromASM('OP_0') })
partial.addInput({
sourceTXID: '66'.repeat(32),
sourceOutputIndex: 0,
unlockingScript: Script.fromASM('OP_0')
})
for (const output of args.outputs ?? []) {
partial.addOutput({ satoshis: output.satoshis, lockingScript: Script.fromHex(output.lockingScript) })
partial.addOutput({
satoshis: output.satoshis,
lockingScript: Script.fromHex(output.lockingScript)
})
}
return {
signableTransaction: { reference: 'Y2xvdWQtYWR2ZXJ0', tx: partial.toAtomicBEEF(true) }
}
return { signableTransaction: { reference: 'Y2xvdWQtYWR2ZXJ0', tx: partial.toAtomicBEEF(true) } }
}),
signAction: jest.fn(async () => ({ tx: partial.toAtomicBEEF(true), txid: partial.id('hex') })),
abortAction: jest.fn(async () => ({ aborted: true }))
Expand Down Expand Up @@ -53,7 +62,9 @@ beforeEach(() => {
jest.clearAllMocks()
jest.restoreAllMocks()
jest.spyOn(SHIPBroadcaster.prototype, 'broadcast').mockImplementation(async tx => ({
status: 'success', txid: tx.id('hex'), message: 'accepted'
status: 'success',
txid: tx.id('hex'),
message: 'accepted'
}))
})

Expand All @@ -71,9 +82,22 @@ it('accepts the canonical UHRP URL form of a hash', async () => {
).resolves.toMatchObject({ txid: expect.any(String) })
})

it('creates an authenticated advertisement for a CHIRP root at its real serving path', async () => {
const root = StorageUtils.getURLForHash(hash)
await expect(
createUHRPAdvertisement({
...valid,
objectIdentifier: root,
url: `https://files.example/chirp/v1/${root}/objects/${root}`
})
).resolves.toMatchObject({ txid: expect.stringMatching(/^[0-9a-f]{64}$/) })
})

it('exposes failure to CHIRP and fails the legacy success wrapper closed', async () => {
const broadcastResult = {
status: 'error', code: 'ERR_NO_HOSTS_INTERESTED', description: 'No host accepted.'
status: 'error',
code: 'ERR_NO_HOSTS_INTERESTED',
description: 'No host accepted.'
}
jest.spyOn(SHIPBroadcaster.prototype, 'broadcast').mockResolvedValue(broadcastResult)
await expect(createUHRPAdvertisementWithResult(valid)).resolves.toMatchObject({ broadcastResult })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,12 @@ function validate(value: unknown): AdvertisementMetadata {
PublicKey.fromString(uploaderIdentityKey)
const hostedFileLocation = text(input.hostedFileLocation, 'UHRP hosted file location', 2048)
const location = new URL(hostedFileLocation)
const isChirpRoot = objectIdentifier === uhrpUrl &&
location.pathname === `/chirp/v1/${objectIdentifier}/objects/${objectIdentifier}`
if (
location.protocol !== 'https:' || location.username !== '' || location.password !== '' ||
location.hash !== '' || location.search !== '' || location.pathname !== `/cdn/${objectIdentifier}`
location.hash !== '' || location.search !== '' ||
(location.pathname !== `/cdn/${objectIdentifier}` && !isChirpRoot)
) throw new Error('UHRP hosted file location is invalid')
const contentType = text(input.contentType, 'UHRP content type', 200)
return {
Expand Down
Loading