OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / client / custody.js

1116 lines · javascript ref:
/**
 * 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)
}