/**
* Custody-log signing — CLIENT (publisher) copy.
*
* This is a self-contained, Bare-safe duplicate of the relay's
* packages/core/core/custody-signing.js. It exists because the client SDK
* (p2p-hiverelay-client, which runs on Bare inside Pear apps like Drop) pins
* an EXACT frozen `p2p-hiverelay@0.7.2` core, and that 0.7.2 core predates
* custody-signing.js — the module simply isn't there to import. Rather than
* loosen the version pin (which would change the relay wire peer the client
* talks to) we carry our own copy.
*
* It is a DUPLICATE, not a shared import: the client and relay are
* independently-versioned wire peers, so the two custody modules must agree
* byte-for-byte on the *signed payload* without sharing code. That agreement
* is pinned by test/unit/client-custody-crossimpl.test.js — the client signs
* an intent/commit here and core's verifyCustodyEntry/validateCustodyTransition
* must accept it unchanged. If you edit signing/normalization here, mirror it
* in core (and vice-versa) or the cross-impl test will fail.
*
* Bare-safety: imports only `b4a` + `sodium-universal` (both Bare-safe). No
* node `crypto`, no `Buffer` — those throw on Bare. The nested shareAssignments
* objects are constructed `{ relayPubkey, shareIndex }` in that exact key order
* because custodySignablePayload stringifies them positionally (JSON.stringify,
* not stableStringify); changing the key order would change every v2 signature.
*/
import b4a from 'b4a'
import sodium from 'sodium-universal'
const SIGNATURE_VERSION = 1
const FUTURE_SKEW_TOLERANCE_MS = 10 * 60 * 1000
const MAX_ENTRY_AGE_MS = 180 * 24 * 60 * 60 * 1000
const HEX_32 = /^[0-9a-f]{64}$/i
const HEX_SIG = /^[0-9a-f]{128}$/i
const HEX_POINT = /^0[23][0-9a-f]{64}$/i // compressed secp256k1 point (33 bytes)
export const FORBIDDEN_KEYS = new Set([
'dataKey',
'decryptionKey',
'plaintext',
'fileName',
'filename',
'path',
'name',
'description',
'author',
'categories',
// PVSS share material — only public commitments / encrypted shares ever
// touch the custody log. Cleartext share scalars, polynomial coefficients
// and the recoverable secret point must never appear in a signed entry.
'share',
'shareScalar',
'decryptedShare',
'secret',
'secretPoint',
'coefficient',
'coefficients',
'polynomial'
])
// Custody entries are versioned. v1 is the original field set; v2 appends the
// optional PVSS share-custody fields (SHARE_FIELDS_BY_TYPE). Gating on version
// keeps every v1 payload byte-identical, so entries already signed by the
// fleet keep verifying after this change.
const SUPPORTED_VERSIONS = new Set([1, 2])
const SHARE_SCHEMES = new Set(['pvss-secp256k1-v1'])
const SIGNER_FIELD_BY_TYPE = {
'custody-intent': 'publisherPubkey',
'custody-commit': 'publisherPubkey',
'source-retired': 'publisherPubkey',
'custody-receipt': 'relayPubkey',
'custody-proof': 'observerPubkey',
'custody-non-serving-proof': 'relayPubkey',
// Witness Tombstone — independent non-storage role that probes a relay
// after `retainUntil` and signs an attestation over observed
// non-serving state. Closes the post-expiry serving leak that the
// simulation surfaced (drops undetected continued serving from ~82%
// to <1% with a 5-of-7 witness quorum).
'custody-expiry-witness': 'witnessPubkey'
}
const SIGNABLE_FIELDS_BY_TYPE = {
'custody-intent': [
'type',
'version',
'timestamp',
'intentId',
'custodyMode',
'addressKey',
'blindContentId',
'contentType',
'ciphertextRoot',
'contentVersion',
'publisherPubkey',
'requiredReplicas',
'candidateRelays',
'deadline',
'retainUntil',
'privacyTier',
'shardPolicy',
'metadataVisibility',
'policyHash'
],
'custody-receipt': [
'type',
'version',
'timestamp',
'intentId',
'custodyMode',
'addressKey',
'blindContentId',
'ciphertextRoot',
'contentVersion',
'relayPubkey',
'relayRegion',
'shardIds',
'anchored',
'retainUntil',
'storageCommitment'
],
'custody-commit': [
'type',
'version',
'timestamp',
'intentId',
'addressKey',
'blindContentId',
'ciphertextRoot',
'contentVersion',
'publisherPubkey',
'relayQuorum',
'receiptRoot',
'nextAuthority'
],
'source-retired': [
'type',
'version',
'timestamp',
'intentId',
'addressKey',
'blindContentId',
'publisherPubkey',
'retiredAtVersion',
'nextAuthority'
],
'custody-proof': [
'type',
'version',
'timestamp',
'intentId',
'blindContentId',
'relayPubkey',
'challengeNonce',
'shardIds',
'blockIndices',
'passed',
'latencyMs',
'observerPubkey'
],
'custody-non-serving-proof': [
'type',
'version',
'timestamp',
'intentId',
'addressKey',
'blindContentId',
'relayPubkey',
'challengeNonce',
'retainUntil',
'notServing',
'notServingReason',
'catalogPresent',
'activeSwarmServing',
'limitationHash'
],
'custody-expiry-witness': [
'type',
'version',
'timestamp',
'intentId',
'blindContentId',
'relayPubkey', // the relay being witnessed (subject)
'witnessPubkey', // the signing witness
'challengeNonce',
'nonServingProofHash', // hash of relay's non-serving-proof we observed
'catalogPresent', // did relay's catalog still expose the entry?
'gatewayServing', // did relay's gateway still serve the content?
'activeSwarmObserved' // did we see active swarm replication?
]
}
// Optional PVSS share-custody fields, appended to the signable payload only
// for version-2 entries. The intent declares the scheme, threshold, the root
// binding the Feldman commitment vector, the hypercore key the public share
// bundle replicates under (shareBundleKey), and the relay→shareIndex map
// (shareAssignments) that tells each custodying relay which encrypted share it
// must verify. The receipt attests which blind share the relay custodies and
// that it publicly verified it (no secret key).
// PARITY WARNING: this is the client's self-contained mirror of core's
// SHARE_FIELDS_BY_TYPE (packages/core/core/custody-signing.js). The two MUST
// produce byte-identical signable payloads or every real client->relay custody
// signature fails INVALID_CUSTODY_ENTRY — pinned by
// test/unit/client-custody-crossimpl.test.js. As of task #115 this copy mirrors
// core's 'shareManifest' field so a browser/Bare dealer can author a
// manifest-bearing v2 intent. 'shareManifest' is OPTIONAL: when absent the
// OPTIONAL_SIGNABLE_FIELDS filter (in custodySignablePayload) drops it from BOTH
// payloads so a manifest-absent intent stays byte-identical to a manifest-absent
// core intent; when present the manifest normalizers below make the signed bytes
// identical to core's. Any edit here MUST be mirrored in core (and vice-versa):
// the field list, OPTIONAL_SIGNABLE_FIELDS, normalizeShareManifest,
// normalizeShardAddressField, and the allowlist all move together.
const SHARE_FIELDS_BY_TYPE = {
'custody-intent': ['shareScheme', 'shareThreshold', 'commitmentRoot', 'shareBundleKey', 'shareAssignments', 'shareManifest'],
'custody-receipt': ['shareScheme', 'commitmentRoot', 'shareIndex', 'shareCommitment', 'shareVerified']
}
const ALLOWED_FIELDS_BY_TYPE = Object.fromEntries(
Object.entries(SIGNABLE_FIELDS_BY_TYPE).map(([type, fields]) => [
type,
new Set([...fields, 'signature'])
])
)
export function isHex32 (value) {
return typeof value === 'string' && HEX_32.test(value)
}
export function hashHex (value) {
const input = typeof value === 'string' || b4a.isBuffer(value)
? b4a.from(value)
: b4a.from(stableStringify(value))
const out = b4a.alloc(32)
sodium.crypto_generichash(out, input)
return b4a.toString(out, 'hex')
}
export function computeReceiptRoot (receipts = []) {
const leaves = receipts
.map(receipt => receipt?.signature || stableStringify(stripSignature(receipt)))
.sort()
return hashHex({ type: 'custody-receipt-root-v1', leaves })
}
export function createCustodyIntent (fields, publisherKeyPair, opts = {}) {
requireKeyPair(publisherKeyPair, 'publisherKeyPair')
const now = Number.isFinite(opts.timestamp) ? opts.timestamp : Date.now()
const publisherPubkey = b4a.toString(publisherKeyPair.publicKey, 'hex')
const raw = {
version: SIGNATURE_VERSION,
timestamp: now,
custodyMode: 'blind',
contentType: 'shard-set',
requiredReplicas: 3,
candidateRelays: [],
deadline: now + 10 * 60 * 1000,
retainUntil: now + 30 * 24 * 60 * 60 * 1000,
privacyTier: 'p2p-only',
shardPolicy: 'all',
metadataVisibility: 'redacted',
policyHash: hashHex({ custodyMode: 'blind', metadataVisibility: 'redacted' }),
...fields,
type: 'custody-intent',
publisherPubkey
}
if (!raw.intentId) {
raw.intentId = hashHex({
type: 'custody-intent-id-v1',
blindContentId: raw.blindContentId,
ciphertextRoot: raw.ciphertextRoot,
contentVersion: raw.contentVersion,
publisherPubkey,
timestamp: raw.timestamp
})
}
const intent = normalizeCustodyEntry(raw, opts)
return signCustodyEntry(intent, publisherKeyPair, opts)
}
export function createCustodyReceipt (fields, relayKeyPair, opts = {}) {
requireKeyPair(relayKeyPair, 'relayKeyPair')
const relayPubkey = b4a.toString(relayKeyPair.publicKey, 'hex')
const raw = {
version: SIGNATURE_VERSION,
timestamp: Number.isFinite(opts.timestamp) ? opts.timestamp : Date.now(),
custodyMode: 'blind',
shardIds: [],
anchored: true,
...fields,
type: 'custody-receipt',
relayPubkey
}
if (!raw.storageCommitment) {
raw.storageCommitment = hashHex({
type: 'custody-storage-commitment-v1',
intentId: raw.intentId,
blindContentId: raw.blindContentId,
ciphertextRoot: raw.ciphertextRoot,
contentVersion: raw.contentVersion,
relayPubkey,
shardIds: raw.shardIds,
anchored: raw.anchored
})
}
const receipt = normalizeCustodyEntry(raw, opts)
return signCustodyEntry(receipt, relayKeyPair, opts)
}
export function createCustodyCommit (fields, publisherKeyPair, opts = {}) {
requireKeyPair(publisherKeyPair, 'publisherKeyPair')
const raw = {
version: SIGNATURE_VERSION,
timestamp: Number.isFinite(opts.timestamp) ? opts.timestamp : Date.now(),
relayQuorum: [],
nextAuthority: null,
...fields,
type: 'custody-commit',
publisherPubkey: b4a.toString(publisherKeyPair.publicKey, 'hex')
}
if (!raw.receiptRoot) raw.receiptRoot = computeReceiptRoot(fields.receipts || [])
delete raw.receipts
const commit = normalizeCustodyEntry(raw, opts)
return signCustodyEntry(commit, publisherKeyPair, opts)
}
export function createSourceRetired (fields, publisherKeyPair, opts = {}) {
requireKeyPair(publisherKeyPair, 'publisherKeyPair')
const retired = normalizeCustodyEntry({
version: SIGNATURE_VERSION,
timestamp: Number.isFinite(opts.timestamp) ? opts.timestamp : Date.now(),
nextAuthority: null,
...fields,
type: 'source-retired',
publisherPubkey: b4a.toString(publisherKeyPair.publicKey, 'hex')
}, opts)
return signCustodyEntry(retired, publisherKeyPair, opts)
}
export function createCustodyProof (fields, observerKeyPair, opts = {}) {
requireKeyPair(observerKeyPair, 'observerKeyPair')
const nonce = b4a.alloc(32)
sodium.randombytes_buf(nonce)
const proof = normalizeCustodyEntry({
version: SIGNATURE_VERSION,
timestamp: Number.isFinite(opts.timestamp) ? opts.timestamp : Date.now(),
challengeNonce: hashHex(nonce),
shardIds: [],
blockIndices: [],
passed: false,
latencyMs: 0,
...fields,
type: 'custody-proof',
observerPubkey: b4a.toString(observerKeyPair.publicKey, 'hex')
}, opts)
return signCustodyEntry(proof, observerKeyPair, opts)
}
export function createCustodyNonServingProof (fields, relayKeyPair, opts = {}) {
requireKeyPair(relayKeyPair, 'relayKeyPair')
const nonce = b4a.alloc(32)
sodium.randombytes_buf(nonce)
const proof = normalizeCustodyEntry({
version: SIGNATURE_VERSION,
timestamp: Number.isFinite(opts.timestamp) ? opts.timestamp : Date.now(),
challengeNonce: hashHex(nonce),
notServing: true,
notServingReason: 'expired-unseeded',
catalogPresent: false,
activeSwarmServing: false,
limitationHash: hashHex('not-serving proof attests active relay state, not forensic disk erasure'),
...fields,
type: 'custody-non-serving-proof',
relayPubkey: b4a.toString(relayKeyPair.publicKey, 'hex')
}, opts)
return signCustodyEntry(proof, relayKeyPair, opts)
}
/**
* Witness Tombstone — independent third-party attestation that a relay
* is no longer actively serving content past `retainUntil`. The witness
* does NOT store content; it only probes the relay's catalog, gateway,
* and swarm and signs over what it observed.
*
* Required fields: intentId, blindContentId, relayPubkey (subject),
* nonServingProofHash (hash of the relay's own non-serving-proof we
* referenced). The witness's pubkey is filled in from the keyPair.
*
* Useful policy: M-of-N witness tombstones, with M and N tuned by
* privacy/integrity requirements. Simulation says 5-of-7 with diverse
* operators drops undetected continued serving from ~82% to <1%.
*/
export function createCustodyExpiryWitness (fields, witnessKeyPair, opts = {}) {
requireKeyPair(witnessKeyPair, 'witnessKeyPair')
const nonce = b4a.alloc(32)
sodium.randombytes_buf(nonce)
const witness = normalizeCustodyEntry({
version: SIGNATURE_VERSION,
timestamp: Number.isFinite(opts.timestamp) ? opts.timestamp : Date.now(),
challengeNonce: hashHex(nonce),
catalogPresent: false,
gatewayServing: false,
activeSwarmObserved: false,
...fields,
type: 'custody-expiry-witness',
witnessPubkey: b4a.toString(witnessKeyPair.publicKey, 'hex')
}, opts)
return signCustodyEntry(witness, witnessKeyPair, opts)
}
export function signCustodyEntry (entry, keyPair, opts = {}) {
requireKeyPair(keyPair, 'keyPair')
const normalized = normalizeCustodyEntry(entry, opts)
const signature = b4a.alloc(sodium.crypto_sign_BYTES)
sodium.crypto_sign_detached(signature, custodySignablePayload(normalized), keyPair.secretKey)
return {
...normalized,
signature: b4a.toString(signature, 'hex')
}
}
export function verifyCustodyEntry (entry, opts = {}) {
try {
const normalized = normalizeCustodyEntry(entry, opts)
const signature = normalized.signature
if (!HEX_SIG.test(signature || '')) return { valid: false, reason: 'bad signature shape' }
const signerField = SIGNER_FIELD_BY_TYPE[normalized.type]
const signer = normalized[signerField]
if (!isHex32(signer)) return { valid: false, reason: `bad ${signerField}` }
const ok = sodium.crypto_sign_verify_detached(
b4a.from(signature, 'hex'),
custodySignablePayload(normalized),
b4a.from(signer, 'hex')
)
if (!ok) return { valid: false, reason: 'bad signature' }
return { valid: true, entry: normalized }
} catch (err) {
return { valid: false, reason: err.message || String(err) }
}
}
export function normalizeCustodyEntry (entry, opts = {}) {
if (!entry || typeof entry !== 'object') throw new Error('custody entry required')
const type = String(entry.type || '').trim()
if (!SIGNER_FIELD_BY_TYPE[type]) throw new Error('unsupported custody entry type')
const version = entry.version || SIGNATURE_VERSION
if (!SUPPORTED_VERSIONS.has(version)) throw new Error('unsupported custody version')
if (version === 2 && !SHARE_FIELDS_BY_TYPE[type]) throw new Error('version 2 unsupported for ' + type)
rejectUnknownFields(entry, type, version)
if (containsForbiddenSecret(entry)) throw new Error('custody entry contains forbidden plaintext/key field')
const now = Number.isFinite(opts.now) ? opts.now : Date.now()
const timestamp = numberField(entry.timestamp, 'timestamp')
if (timestamp > now + FUTURE_SKEW_TOLERANCE_MS) throw new Error('timestamp too far in future')
if (timestamp < now - MAX_ENTRY_AGE_MS) throw new Error('timestamp too old')
const out = {
...entry,
type,
version,
timestamp
}
out.intentId = hexField(out.intentId, 'intentId')
if (type === 'custody-intent' || type === 'custody-receipt') {
out.custodyMode = String(out.custodyMode || 'blind')
if (out.custodyMode !== 'blind') throw new Error('custodyMode must be blind')
}
if (type !== 'source-retired' && type !== 'custody-proof' && type !== 'custody-non-serving-proof' && type !== 'custody-expiry-witness') {
out.ciphertextRoot = hexField(out.ciphertextRoot, 'ciphertextRoot')
out.contentVersion = numberField(out.contentVersion, 'contentVersion')
}
if (out.addressKey != null) out.addressKey = hexField(out.addressKey, 'addressKey')
out.blindContentId = hexField(out.blindContentId, 'blindContentId')
if (type === 'custody-intent') return normalizeIntent(out)
if (type === 'custody-receipt') return normalizeReceipt(out)
if (type === 'custody-commit') return normalizeCommit(out)
if (type === 'source-retired') return normalizeSourceRetired(out)
if (type === 'custody-proof') return normalizeProof(out)
if (type === 'custody-non-serving-proof') return normalizeNonServingProof(out)
if (type === 'custody-expiry-witness') return normalizeExpiryWitness(out)
}
export function validateCustodyTransition (entry, status = {}) {
if (!entry || !entry.type) return { valid: false, reason: 'entry required' }
const intent = status.intent
// Claim path. A validated source-retirement (publisher signed "I deleted
// the source" — which itself requires a custody commit, so the recipient/
// quorum already holds the content) discharges the relay's serving
// obligation BEFORE retainUntil. On that path a non-serving-proof or
// expiry-witness timestamped before retainUntil is legitimate, not
// premature. Outside it, the retainUntil floor still holds.
// getCustodyStatus() exposes the retirement as `sourceRetirement`;
// summarizeCustodyStatus() passes it as `sourceRetired`.
const sourceRetired = !!(status.sourceRetired || status.sourceRetirement)
if (entry.type === 'custody-receipt') {
if (!intent) return { valid: true }
if (entry.blindContentId !== intent.blindContentId) return { valid: false, reason: 'blindContentId mismatch' }
if (entry.ciphertextRoot !== intent.ciphertextRoot) return { valid: false, reason: 'ciphertextRoot mismatch' }
if (entry.contentVersion !== intent.contentVersion) return { valid: false, reason: 'contentVersion mismatch' }
if (entry.timestamp > intent.deadline) return { valid: false, reason: 'receipt after deadline' }
if (entry.retainUntil < intent.retainUntil) return { valid: false, reason: 'retainUntil below intent' }
// PVSS binding. If the intent declares share custody, the receipt must
// echo the same scheme + commitment vector, name a share index inside the
// dealer's range, and assert it publicly verified the encrypted share.
// Conversely a receipt cannot claim share custody for a non-PVSS intent.
if (intent.shareScheme) {
if (entry.shareScheme !== intent.shareScheme) return { valid: false, reason: 'shareScheme mismatch' }
if (entry.commitmentRoot !== intent.commitmentRoot) return { valid: false, reason: 'commitmentRoot mismatch' }
if (!Number.isInteger(entry.shareIndex) || entry.shareIndex < 1 || entry.shareIndex > intent.requiredReplicas) {
return { valid: false, reason: 'shareIndex out of range' }
}
// The relay must custody the exact share the dealer assigned it. The
// signed shareAssignments map binds relayPubkey → shareIndex, so a relay
// cannot claim a different share (or another relay's share).
if (Array.isArray(intent.shareAssignments)) {
const assigned = intent.shareAssignments.find(a => a.relayPubkey === entry.relayPubkey)
if (!assigned) return { valid: false, reason: 'relay not in shareAssignments' }
if (assigned.shareIndex !== entry.shareIndex) return { valid: false, reason: 'shareIndex does not match assignment' }
}
// When the intent binds shares to content-addressed shards, the receipt's
// shareIndex must exist in the signed manifest and its public commitment
// must equal the manifest's binding — so a relay cannot attest a share
// whose blob the dealer never committed to. Mirror of core's manifest
// binding block in validateCustodyTransition.
if (Array.isArray(intent.shareManifest)) {
const bound = intent.shareManifest.find(m => m.shareIndex === entry.shareIndex)
if (!bound) return { valid: false, reason: 'shareIndex not in shareManifest' }
if (entry.shareCommitment && entry.shareCommitment !== bound.shareCommitment) {
return { valid: false, reason: 'shareCommitment does not match manifest binding' }
}
}
if (entry.shareVerified !== true) return { valid: false, reason: 'shareVerified must be true' }
} else if (entry.shareScheme) {
return { valid: false, reason: 'share custody declared for non-PVSS intent' }
}
}
if (entry.type === 'custody-commit' || entry.type === 'source-retired') {
if (intent && entry.publisherPubkey !== intent.publisherPubkey) {
return { valid: false, reason: 'publisherPubkey mismatch' }
}
}
if (entry.type === 'custody-commit' && intent) {
const anchored = Array.isArray(status.receipts) ? status.receipts.filter(r => r.anchored === true) : []
// Partial-quorum support: when the commit carries an explicit
// `relayQuorum` (the publisher's canonical receipt set), validate
// against ONLY the receipts from those pubkeys — not the full set
// of receipts the relay happens to have visible.
//
// Pre-this-change, validation used `anchored` directly. That worked
// for t === n quorums but rejected any partial-quorum (t < n) commit
// as `receiptRoot mismatch` or `relayQuorum mismatch` the moment the
// relay had MORE receipts than the publisher's chosen subset (e.g.
// receipt #4 arrived via gossip between collect-threshold and
// commit-submit). drop-pear's v3.0.7 → v3.0.13 partial-quorum design
// (Shamir threshold < replication factor) hit this in production
// against ≥4-relay quorums; v3.0.14 worked around it by reverting to
// wait-for-n-receipts.
//
// Treating `commit.relayQuorum` as authoritative makes T-of-N quorums
// work properly: publisher picks the receipts to commit, relay verifies
// those specific receipts exist + sign correctly, the publisher
// signature on the commit binds the chosen set so no one can swap
// receipts after the fact. Backwards-compatible: an empty/missing
// `relayQuorum` falls back to the original "use all visible" behavior.
let receipts
if (Array.isArray(entry.relayQuorum) && entry.relayQuorum.length > 0) {
const quorumSet = new Set(entry.relayQuorum)
receipts = anchored.filter(r => quorumSet.has(r.relayPubkey))
// Must have a receipt for every pubkey the commit names — the
// publisher can't reference receipts the relay can't see.
if (receipts.length !== entry.relayQuorum.length) {
return { valid: false, reason: 'relayQuorum names receipts not yet visible to this relay' }
}
} else {
receipts = anchored
}
if (receipts.length < intent.requiredReplicas) return { valid: false, reason: 'quorum not reached' }
const receiptRoot = computeReceiptRoot(receipts)
if (entry.receiptRoot !== receiptRoot) return { valid: false, reason: 'receiptRoot mismatch' }
const quorum = receipts.map(r => r.relayPubkey).sort()
if (stableStringify(entry.relayQuorum) !== stableStringify(quorum)) {
return { valid: false, reason: 'relayQuorum mismatch' }
}
// PVSS quorum must reconstruct: every committed receipt carries a share
// index, and those indices are distinct (duplicate indices contribute the
// same Lagrange point and cannot recover the secret).
if (intent.shareScheme) {
const indices = receipts.map(r => r.shareIndex)
if (indices.some(i => !Number.isInteger(i) || i < 1)) {
return { valid: false, reason: 'committed receipt missing shareIndex' }
}
if (new Set(indices).size !== indices.length) {
return { valid: false, reason: 'duplicate shareIndex in quorum' }
}
}
}
if (entry.type === 'source-retired' && !status.commit) {
return { valid: false, reason: 'custody commit required before source retirement' }
}
if (entry.type === 'custody-non-serving-proof') {
if (intent && entry.blindContentId !== intent.blindContentId) return { valid: false, reason: 'blindContentId mismatch' }
if (intent && entry.addressKey !== intent.addressKey) return { valid: false, reason: 'addressKey mismatch' }
if (intent && entry.retainUntil < intent.retainUntil) return { valid: false, reason: 'retainUntil below intent' }
if (intent && entry.timestamp < intent.retainUntil && !sourceRetired) {
return { valid: false, reason: 'non-serving proof before retainUntil' }
}
if (entry.notServing !== true) return { valid: false, reason: 'notServing must be true' }
if (entry.catalogPresent || entry.activeSwarmServing) return { valid: false, reason: 'relay still reports active serving state' }
}
if (entry.type === 'custody-expiry-witness') {
if (!intent) return { valid: false, reason: 'custody intent required before witness tombstone' }
if (entry.blindContentId !== intent.blindContentId) return { valid: false, reason: 'blindContentId mismatch' }
if (entry.timestamp < intent.retainUntil && !sourceRetired) {
return { valid: false, reason: 'witness before retainUntil' }
}
if (entry.catalogPresent || entry.gatewayServing || entry.activeSwarmObserved) {
return { valid: false, reason: 'witness observed active serving state' }
}
const nonServingProofs = Array.isArray(status.nonServingProofs) ? status.nonServingProofs : []
const matchesProof = nonServingProofs.some(proof => {
if (!proof || proof.relayPubkey !== entry.relayPubkey) return false
if (proof.notServing !== true) return false
return hashHex(proof) === entry.nonServingProofHash
})
if (!matchesProof) return { valid: false, reason: 'matching non-serving proof required before witness tombstone' }
}
return { valid: true }
}
export function summarizeCustodyStatus (intent, receipts = [], commit = null, retirement = null, proofs = [], nonServingProofs = [], expiryWitnesses = [], witnessPolicy = null) {
const requiredReplicas = intent?.requiredReplicas || 0
const validReceipts = receipts.filter(r => r.anchored === true)
const validExpiryWitnesses = expiryWitnesses.filter(w => validateCustodyTransition(w, { intent, nonServingProofs, sourceRetired: retirement }).valid)
const summary = {
intentId: intent?.intentId || null,
blindContentId: intent?.blindContentId || null,
custodyMode: intent?.custodyMode || 'blind',
requiredReplicas,
receiptCount: validReceipts.length,
quorumReached: requiredReplicas > 0 && validReceipts.length >= requiredReplicas,
receiptRoot: computeReceiptRoot(validReceipts),
relayQuorum: validReceipts.map(r => r.relayPubkey).sort(),
committed: !!commit,
sourceRetired: !!retirement,
proofCount: proofs.length,
passingProofs: proofs.filter(p => p.passed === true).length,
nonServingProofCount: nonServingProofs.length,
nonServingRelays: nonServingProofs.filter(p => p.notServing === true).map(p => p.relayPubkey).sort(),
expiryWitnessCount: expiryWitnesses.length,
validExpiryWitnessCount: validExpiryWitnesses.length,
expiryWitnessRelays: validExpiryWitnesses.map(w => w.relayPubkey).sort()
}
if (witnessPolicy) {
summary.expiryWitnessQuorum = evaluateExpiryWitnessQuorum(witnessPolicy, validExpiryWitnesses)
}
// PVSS block added only when the intent declares share custody, so every
// non-PVSS summary stays byte-identical to before this change.
if (intent?.shareScheme) {
summary.pvss = {
shareScheme: intent.shareScheme,
shareThreshold: intent.shareThreshold,
commitmentRoot: intent.commitmentRoot,
shareIndices: [...new Set(validReceipts.map(r => r.shareIndex).filter(i => Number.isInteger(i)))].sort((a, b) => a - b)
}
}
return summary
}
function evaluateExpiryWitnessQuorum (policy, entries = []) {
const validation = validateExpiryWitnessPolicy(policy)
const required = nonNegativeIntegerOr(policy?.requiredWitnesses, 0)
if (!validation.valid) {
return {
valid: false,
count: 0,
required,
accepted: [],
rejected: [],
reason: 'invalid-policy',
errors: validation.errors,
warnings: validation.warnings
}
}
const selected = new Set(policy.witnesses.map(witness => witness.witnessPubkey))
const accepted = []
const rejected = []
const seen = new Set()
for (const entry of Array.isArray(entries) ? entries : []) {
const witnessPubkey = typeof entry?.witnessPubkey === 'string' ? entry.witnessPubkey.toLowerCase() : null
const reason = rejectExpiryWitnessQuorumEntry(policy, selected, seen, entry, witnessPubkey)
if (reason) {
rejected.push({ witnessPubkey, reason })
continue
}
seen.add(witnessPubkey)
accepted.push(witnessPubkey)
}
return {
valid: accepted.length >= policy.requiredWitnesses,
count: accepted.length,
required: policy.requiredWitnesses,
accepted,
rejected,
reason: accepted.length >= policy.requiredWitnesses ? null : 'witness-quorum-not-reached'
}
}
function validateExpiryWitnessPolicy (policy) {
const errors = []
const warnings = []
if (!policy || typeof policy !== 'object') return { valid: false, errors: ['policy object required'], warnings }
if (policy.kind && policy.kind !== 'hivemesh-witness-quorum-policy') errors.push('kind mismatch')
if (policy.version != null && policy.version !== 1) errors.push('version mismatch')
if (!isHex32(policy.intentId)) errors.push('intentId required')
if (!isHex32(policy.subjectRelayPubkey)) errors.push('subjectRelayPubkey required')
if (!isHex32(policy.publisherPubkey)) errors.push('publisherPubkey required')
if (policy.selectedBy !== 'publisher') errors.push('witness set must be publisher-selected')
const witnesses = Array.isArray(policy.witnesses) ? policy.witnesses : []
if (nonNegativeIntegerOr(policy.witnessCount, witnesses.length) !== witnesses.length) {
errors.push('witnessCount does not match witness list')
}
if (!Number.isSafeInteger(policy.requiredWitnesses) || policy.requiredWitnesses <= 0) {
errors.push('requiredWitnesses must be positive')
} else if (policy.requiredWitnesses > witnesses.length) {
errors.push('requiredWitnesses exceeds witnessCount')
}
const minOperators = nonNegativeIntegerOr(policy.minOperators, 0)
const minRegions = nonNegativeIntegerOr(policy.minRegions, 0)
if (!Number.isSafeInteger(policy.minOperators) || policy.minOperators < 0) errors.push('minOperators must be a non-negative integer')
if (!Number.isSafeInteger(policy.minRegions) || policy.minRegions < 0) errors.push('minRegions must be a non-negative integer')
const seen = new Set()
for (const witness of witnesses) {
if (!witness || typeof witness !== 'object') {
errors.push('witness object required')
continue
}
if (!isHex32(witness.witnessPubkey)) errors.push('bad witnessPubkey')
else {
const witnessPubkey = witness.witnessPubkey.toLowerCase()
if (seen.has(witnessPubkey)) errors.push('duplicate witnessPubkey: ' + witnessPubkey)
else seen.add(witnessPubkey)
if (witnessPubkey === policy.subjectRelayPubkey) errors.push('subject relay cannot witness itself')
if (witnessPubkey === policy.publisherPubkey) warnings.push('publisher is also a witness: ' + witnessPubkey)
}
if (!witness.operator) errors.push('witness operator required')
if (!witness.region) errors.push('witness region required')
}
const summary = summarizeExpiryWitnessPolicy(witnesses)
if (summary.operators.length < minOperators) errors.push('insufficient witness operator diversity')
if (summary.regions.length < minRegions) errors.push('insufficient witness region diversity')
return { valid: errors.length === 0, errors, warnings }
}
function rejectExpiryWitnessQuorumEntry (policy, selected, seen, entry, witnessPubkey) {
if (!entry || typeof entry !== 'object') return 'entry required'
if (!isHex32(witnessPubkey)) return 'bad witnessPubkey'
if (!selected.has(witnessPubkey)) return 'witness not publisher-selected'
if (seen.has(witnessPubkey)) return 'duplicate witness'
if (entry.intentId !== policy.intentId) return 'intentId mismatch'
if (entry.relayPubkey !== policy.subjectRelayPubkey) return 'subject relay mismatch'
if (entry.catalogPresent || entry.gatewayServing || entry.activeSwarmObserved) return 'witness observed active serving'
return null
}
function summarizeExpiryWitnessPolicy (witnesses) {
const rows = Array.isArray(witnesses) ? witnesses.filter(witness => witness && typeof witness === 'object') : []
return {
operators: uniqueSorted(rows.map(witness => witness.operator).filter(Boolean)),
regions: uniqueSorted(rows.map(witness => witness.region).filter(Boolean))
}
}
function uniqueSorted (values) {
return [...new Set(values)].sort()
}
function nonNegativeIntegerOr (value, fallback) {
return Number.isSafeInteger(value) && value >= 0 ? value : fallback
}
function normalizeIntent (entry) {
entry.publisherPubkey = hexField(entry.publisherPubkey, 'publisherPubkey')
entry.requiredReplicas = positiveInteger(entry.requiredReplicas, 'requiredReplicas')
entry.deadline = numberField(entry.deadline, 'deadline')
entry.retainUntil = numberField(entry.retainUntil, 'retainUntil')
entry.contentType = String(entry.contentType || 'shard-set')
entry.candidateRelays = normalizeHexArray(entry.candidateRelays, 'candidateRelays')
entry.shardPolicy = String(entry.shardPolicy || 'all')
entry.privacyTier = String(entry.privacyTier || 'p2p-only')
entry.metadataVisibility = String(entry.metadataVisibility || 'redacted')
entry.policyHash = hexField(entry.policyHash || hashHex({
privacyTier: entry.privacyTier,
metadataVisibility: entry.metadataVisibility,
shardPolicy: entry.shardPolicy
}), 'policyHash')
if (entry.privacyTier !== 'public' && entry.metadataVisibility !== 'redacted') {
throw new Error('private custody metadataVisibility must be redacted')
}
if (entry.version === 2) normalizeShareIntentFields(entry)
return orderedEntry(entry)
}
// v2 intent: declare the PVSS scheme, the recovery threshold, the root binding
// the published Feldman commitment vector, the hypercore key the public share
// bundle replicates under (shareBundleKey), and the relay→shareIndex map
// (shareAssignments). threshold ≤ requiredReplicas (you cannot need more honest
// shares than relays you asked to custody them).
function normalizeShareIntentFields (entry) {
entry.shareScheme = shareSchemeField(entry.shareScheme)
entry.shareThreshold = positiveInteger(entry.shareThreshold, 'shareThreshold')
if (entry.shareThreshold > entry.requiredReplicas) {
throw new Error('shareThreshold exceeds requiredReplicas')
}
entry.commitmentRoot = hexField(entry.commitmentRoot, 'commitmentRoot')
entry.shareBundleKey = hexField(entry.shareBundleKey, 'shareBundleKey')
entry.shareAssignments = normalizeShareAssignments(entry.shareAssignments, entry.requiredReplicas, entry.shareThreshold)
const manifest = normalizeShareManifest(entry.shareManifest, entry.requiredReplicas)
if (manifest === undefined) delete entry.shareManifest
else entry.shareManifest = manifest
}
// shareManifest binds shareIndex -> content-addressed shard:<hash> (+ the
// per-share public commitment). Optional: a v2 intent may still use the
// shareBundleKey bundle instead. When present it is signed with the intent.
// Byte-identical mirror of core's normalizeShareManifest.
function normalizeShareManifest (value, requiredReplicas) {
if (value == null) return undefined
if (!Array.isArray(value)) throw new Error('shareManifest must be an array')
const seen = new Set()
const out = value.map((raw) => {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('shareManifest entry must be an object')
const shareIndex = positiveInteger(raw.shareIndex, 'shareManifest.shareIndex')
if (shareIndex > requiredReplicas) throw new Error('shareManifest shareIndex exceeds requiredReplicas')
if (seen.has(shareIndex)) throw new Error('duplicate shareIndex in shareManifest')
seen.add(shareIndex)
return {
shareIndex,
shard: normalizeShardAddressField(raw.shard, 'shareManifest.shard'),
// Same compressed secp256k1 point the receipt commits to (66 hex).
shareCommitment: pointField(raw.shareCommitment, 'shareManifest.shareCommitment')
}
})
out.sort((a, b) => a.shareIndex - b.shareIndex)
return out
}
function normalizeShardAddressField (value, name) {
if (typeof value !== 'string') throw new Error(name + ' must be a string')
const raw = value.startsWith('shard:') ? value.slice('shard:'.length) : value
const lower = raw.toLowerCase()
if (!HEX_32.test(lower)) throw new Error(name + ' must be shard:<64-hex>')
return 'shard:' + lower
}
// Each PVSS share is encrypted to a guardian and custodied by a specific relay.
// shareAssignments is the signed relay→shareIndex map: every custodying relay
// learns which encrypted share it must fetch from the bundle and verify.
// Validated here so a malformed map can never be signed — a non-empty array of
// { relayPubkey, shareIndex }, distinct relays AND distinct indices, each index
// inside the dealer's [1, requiredReplicas] range, and at least `shareThreshold`
// of them (fewer assignments than the threshold could never reconstruct). The
// objects are rebuilt relayPubkey-first and sorted by shareIndex so the signed
// JSON is deterministic: custodySignablePayload stringifies these nested objects
// positionally (JSON.stringify, not stableStringify), so the client prover must
// construct them in this exact key order to produce a byte-identical signature.
function normalizeShareAssignments (value, requiredReplicas, shareThreshold) {
if (!Array.isArray(value) || value.length === 0) {
throw new Error('shareAssignments must be a non-empty array')
}
const relays = new Set()
const indices = new Set()
const out = value.map(a => {
if (!a || typeof a !== 'object') throw new Error('shareAssignment must be an object')
rejectUnknownObjectFields(a, ['relayPubkey', 'shareIndex'], 'shareAssignment')
const relayPubkey = hexField(a.relayPubkey, 'shareAssignment.relayPubkey')
const shareIndex = positiveInteger(a.shareIndex, 'shareAssignment.shareIndex')
if (shareIndex > requiredReplicas) throw new Error('shareAssignment.shareIndex exceeds requiredReplicas')
if (relays.has(relayPubkey)) throw new Error('duplicate relayPubkey in shareAssignments')
if (indices.has(shareIndex)) throw new Error('duplicate shareIndex in shareAssignments')
relays.add(relayPubkey)
indices.add(shareIndex)
return { relayPubkey, shareIndex }
})
if (out.length < shareThreshold) {
throw new Error('shareAssignments fewer than shareThreshold')
}
return out.sort((a, b) => a.shareIndex - b.shareIndex)
}
function normalizeReceipt (entry) {
entry.relayPubkey = hexField(entry.relayPubkey, 'relayPubkey')
entry.relayRegion = typeof entry.relayRegion === 'string' && entry.relayRegion ? entry.relayRegion : 'unknown'
entry.shardIds = normalizeIntegerArray(entry.shardIds, 'shardIds')
entry.anchored = entry.anchored === true
entry.retainUntil = numberField(entry.retainUntil, 'retainUntil')
entry.storageCommitment = hexField(entry.storageCommitment, 'storageCommitment')
if (entry.version === 2) normalizeShareReceiptFields(entry)
return orderedEntry(entry)
}
// v2 receipt: the relay attests which blind share index it custodies, the
// per-index commitment point it pins, and that it PUBLICLY verified the
// encrypted share against the commitments (no secret key). The commitmentRoot
// echo lets the transition check bind the receipt to the intent's vector.
function normalizeShareReceiptFields (entry) {
entry.shareScheme = shareSchemeField(entry.shareScheme)
entry.commitmentRoot = hexField(entry.commitmentRoot, 'commitmentRoot')
entry.shareIndex = positiveInteger(entry.shareIndex, 'shareIndex')
entry.shareCommitment = pointField(entry.shareCommitment, 'shareCommitment')
entry.shareVerified = entry.shareVerified === true
}
function normalizeCommit (entry) {
entry.publisherPubkey = hexField(entry.publisherPubkey, 'publisherPubkey')
entry.relayQuorum = normalizeHexArray(entry.relayQuorum, 'relayQuorum')
entry.receiptRoot = hexField(entry.receiptRoot, 'receiptRoot')
if (entry.nextAuthority != null) entry.nextAuthority = hexField(entry.nextAuthority, 'nextAuthority')
else entry.nextAuthority = null
return orderedEntry(entry)
}
function normalizeSourceRetired (entry) {
entry.publisherPubkey = hexField(entry.publisherPubkey, 'publisherPubkey')
entry.retiredAtVersion = numberField(entry.retiredAtVersion, 'retiredAtVersion')
if (entry.nextAuthority != null) entry.nextAuthority = hexField(entry.nextAuthority, 'nextAuthority')
else entry.nextAuthority = null
return orderedEntry(entry)
}
function normalizeProof (entry) {
entry.relayPubkey = hexField(entry.relayPubkey, 'relayPubkey')
entry.challengeNonce = hexField(entry.challengeNonce, 'challengeNonce')
entry.shardIds = normalizeIntegerArray(entry.shardIds, 'shardIds')
entry.blockIndices = normalizeIntegerArray(entry.blockIndices, 'blockIndices')
entry.passed = entry.passed === true
entry.latencyMs = nonNegativeNumber(entry.latencyMs, 'latencyMs')
entry.observerPubkey = hexField(entry.observerPubkey, 'observerPubkey')
return orderedEntry(entry)
}
function normalizeNonServingProof (entry) {
entry.addressKey = hexField(entry.addressKey, 'addressKey')
entry.relayPubkey = hexField(entry.relayPubkey, 'relayPubkey')
entry.challengeNonce = hexField(entry.challengeNonce, 'challengeNonce')
entry.retainUntil = numberField(entry.retainUntil, 'retainUntil')
entry.notServing = entry.notServing === true
entry.notServingReason = String(entry.notServingReason || 'expired-unseeded').slice(0, 120)
entry.catalogPresent = entry.catalogPresent === true
entry.activeSwarmServing = entry.activeSwarmServing === true
entry.limitationHash = hexField(entry.limitationHash, 'limitationHash')
return orderedEntry(entry)
}
function normalizeExpiryWitness (entry) {
entry.relayPubkey = hexField(entry.relayPubkey, 'relayPubkey')
entry.witnessPubkey = hexField(entry.witnessPubkey, 'witnessPubkey')
entry.challengeNonce = hexField(entry.challengeNonce, 'challengeNonce')
entry.nonServingProofHash = hexField(entry.nonServingProofHash, 'nonServingProofHash')
entry.catalogPresent = entry.catalogPresent === true
entry.gatewayServing = entry.gatewayServing === true
entry.activeSwarmObserved = entry.activeSwarmObserved === true
return orderedEntry(entry)
}
// A signature-covered field that is OPTIONAL (may be legitimately absent) must
// be omitted from the payload when absent — never emitted as `null`. Otherwise
// a payload that carries `[field, null]` diverges from every signer that omits
// the field entirely (core when the manifest is absent, and every entry of this
// version signed before the field was introduced). Byte-identical mirror of
// core's OPTIONAL_SIGNABLE_FIELDS + custodySignablePayload filter.
const OPTIONAL_SIGNABLE_FIELDS = new Set(['shareManifest'])
function custodySignablePayload (entry) {
const fields = signableFieldsFor(entry).filter(
field => !OPTIONAL_SIGNABLE_FIELDS.has(field) || entry[field] !== undefined
)
const pairs = fields.map(field => [field, entry[field] ?? null])
return b4a.from(`hiverelay-${entry.type}-v1:${JSON.stringify(pairs)}`)
}
// v1 signs the base field set; v2 appends the PVSS share fields (if any) for
// this type. The `-v1:` domain tag is a fixed protocol label, NOT the entry
// version — it must never change or every existing signature breaks. A v1
// entry therefore produces a byte-identical payload to before this change.
function signableFieldsFor (entry) {
const base = SIGNABLE_FIELDS_BY_TYPE[entry.type]
const ext = entry.version === 2 ? SHARE_FIELDS_BY_TYPE[entry.type] : null
return ext ? base.concat(ext) : base
}
function stableStringify (value) {
if (value === null || typeof value !== 'object') return JSON.stringify(value)
if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']'
const keys = Object.keys(value).sort()
return '{' + keys.map(key => JSON.stringify(key) + ':' + stableStringify(value[key])).join(',') + '}'
}
function stripSignature (entry) {
const out = { ...entry }
delete out.signature
return out
}
function orderedEntry (entry) {
const out = {}
for (const key of Object.keys(entry).sort()) out[key] = entry[key]
return out
}
function containsForbiddenSecret (value) {
if (!value || typeof value !== 'object') return false
for (const [key, child] of Object.entries(value)) {
if (FORBIDDEN_KEYS.has(key)) return true
if (child && typeof child === 'object' && containsForbiddenSecret(child)) return true
}
return false
}
function rejectUnknownFields (entry, type, version) {
const allowed = allowedFieldsFor(type, version)
for (const key of Object.keys(entry)) {
if (!allowed.has(key)) throw new Error(`unknown custody field: ${key}`)
}
}
function rejectUnknownObjectFields (entry, fields, label) {
const allowed = new Set(fields)
for (const key of Object.keys(entry)) {
if (!allowed.has(key)) throw new Error(`unknown ${label} field: ${key}`)
}
}
// v1 accepts only the base allow-list; v2 additionally accepts that type's
// PVSS share fields. A v1 entry carrying a share field is therefore rejected
// as an unknown field — the new surface is opt-in per version.
function allowedFieldsFor (type, version) {
const base = ALLOWED_FIELDS_BY_TYPE[type]
if (version === 2 && SHARE_FIELDS_BY_TYPE[type]) {
return new Set([...base, ...SHARE_FIELDS_BY_TYPE[type]])
}
return base
}
function requireKeyPair (keyPair, name) {
if (!keyPair || !keyPair.publicKey || !keyPair.secretKey) {
throw new Error(`${name} { publicKey, secretKey } required`)
}
}
function hexField (value, name) {
if (!isHex32(value)) throw new Error(`${name} must be 64 hex characters`)
return value.toLowerCase()
}
function shareSchemeField (value) {
if (typeof value !== 'string' || !SHARE_SCHEMES.has(value)) throw new Error('unsupported shareScheme')
return value
}
function pointField (value, name) {
if (typeof value !== 'string' || !HEX_POINT.test(value)) {
throw new Error(`${name} must be a compressed secp256k1 point (66 hex chars)`)
}
return value.toLowerCase()
}
function numberField (value, name) {
if (!Number.isFinite(value) || value < 0) throw new Error(`${name} must be a non-negative number`)
return Math.floor(value)
}
function positiveInteger (value, name) {
const n = numberField(value, name)
if (n < 1) throw new Error(`${name} must be at least 1`)
return n
}
function nonNegativeNumber (value, name) {
if (!Number.isFinite(value) || value < 0) throw new Error(`${name} must be a non-negative number`)
return value
}
function normalizeHexArray (value, name) {
if (value == null) return []
if (!Array.isArray(value)) throw new Error(`${name} must be an array`)
return [...new Set(value.map(v => hexField(v, name)))].sort()
}
function normalizeIntegerArray (value, name) {
if (value == null) return []
if (!Array.isArray(value)) throw new Error(`${name} must be an array`)
return [...new Set(value.map(v => {
if (!Number.isInteger(v) || v < 0) throw new Error(`${name} must contain non-negative integers`)
return v
}))].sort((a, b) => a - b)
}