1. Abstract
CIP-24 specifies the Cowboy Secret Service (CBSS): how an account stores third-party API credentials such that- plaintext is never visible on chain or to validators,
- secrets can be deleted forever (no archival ciphertext that becomes decryptable as cryptography ages),
- only the specific keys an actor names at the call site are released to the runner,
- release is just-in-time and the dispatcher is not in the trust path,
- cross-account sharing is supported, and
- the trust root is t-of-n proxy non-collusion, not a hardware enclave.
0x0000…0004. Off chain, CBSS introduces a new staked operator role — the CBSS Proxy (SecretsProxy) — running the cbssd daemon. Secrets are owned by accounts, not actors; an account’s actors are consumers gated by per-secret ACL. Each per-account release keypair (MPK ∈ G2, MSK ∈ scalar field) is established by a single threshold BLS DKG ceremony among n proxies — once per account, regardless of how many actors or secrets that account ever has. MSK is never reified, only Shamir-shared. At release time, any t proxies return BLS partial signatures on the per-version identity; the runner Lagrange-combines them into the IBE decryption key and unwraps the DEK. Defaults: n = 5, t = 4, BLS12-381. The threshold follows the vetted Commonware N3f1 DKG quorum for the five-proxy default.
For high-value secrets where the per-account blast radius is unacceptable, an owner MAY specify a per-secret committee override at SetSecret time. The override triggers a one-off DKG for that specific secret-version with its own (MPK, MSK, committee, t, n), isolated from the account default. This is the escape hatch for owners who want a unique committee for STRIPE_LIVE_KEY while letting SLACK_API_KEY ride the account default.
CBSS rejects TEE as the trust root for secret release. CIP-23 TEE attestation remains available as an additional gate per secret (the tee_required flag), but the underlying release is unconditionally non-TEE. Rationale in §10.
Crypto stack: CBSS uses BLS12-381 with a threshold identity-based-encryption (IBE) construction, equivalent to drand’s “tlock” scheme. Vetted libraries are required — blstrs for curve ops, the pinned Commonware BLS DKG state machine (or an equivalent vetted threshold-BLS DKG), and tlock (drand) for the IBE construction. BLS12-381 is a new curve dependency for Cowboy but does not replace secp256k1 (account keys remain secp256k1); it lives alongside it as a CBSS-specific primitive. See §3.4.
The whole design rests on one structural decision: the cryptographic principal is a stable on-chain identity (account by default, secret on override) whose private key is never reified — only Shamir-shared across the proxy committee. This is the load-bearing change that makes pull-without-dispatcher and pull-without-TEE both work. The owner is offline at job time so cannot be the principal; the runner is VRF-selected at job time so per-recipient material cannot be pre-issued; therefore the principal is an on-chain identity whose key materializes only as a t-of-n threshold operation.
2. Motivation
2.1 The problem
Actors call third-party APIs (Slack, OpenAI, GitHub, Stripe, etc.) that require credentials. Naive solutions — hard-coding into actor source, reading from a CBFS plaintext file, decrypting in actor PVM Python — all violate at least one of:- Validator privacy: validators replay actor execution; anything readable in the PVM is readable on chain.
- Eventual decryptability: anything written to chain history is decryptable forever as cryptography ages. Secrets leak retroactively.
- Runtime isolation: an actor that can read
OPENAI_API_KEYshould not also be able to readSTRIPE_LIVE_KEYjust because the same account owns both. - Dispatcher trust: a dispatcher that can unwrap credentials or volume DEKs becomes a plaintext custodian. CBSS keeps the dispatcher off the key path for both.
2.2 Design philosophy
The protocol provides the minimal release surface: encrypted at-rest in CBFS, threshold-released through a registered proxy network, and delivered into an in-memory runner bundle for a single runner-op (with explicit subprocess env maps only when required). Every other concern — rotation policies, audit pipelines, HSM-backed wrapping, DAO-controlled secrets — is built by application-layer actors on top of this primitive. Following the precedent set by CIP-13 §2.1: protocol provides hooks, ecosystem builds products.2.3 Why not TEE-rooted release
CIP-23 ships TEE-attested execution. That solves a different problem: bounding the runner host’s introspection of in-flight actor state. Using the same anchor for secret release would mean every secret on the network becomes retroactively exfiltratable on a single CPU-vendor microarchitectural break (Foreshadow / Plundervolt / SGAxe / CipherLeaks have all happened to deployed TEEs). Secret ciphertext is durable; an enclave compromise is forever for the secrets it ever wrapped. CBSS instead anchors trust in t-of-n proxy non-collusion plus economic slashing. The failure mode (≥ t simultaneous proxy compromises) is independent of any single hardware vendor and is hardenable through operator diversity policy. §9 quantifies the trade.3. Specification
Normative conventions. The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT, and MAY in this document are to be interpreted as described in RFC 2119. Parameters marked governance-tunable can be changed by on-chain governance (§4).3.1 New data structures
SecretId
KeyName (plaintext, regex [A-Z][A-Z0-9_]{0,127}, case-sensitive ASCII) appears in CLI / SDK input, in actor manifest entitlements, and in the runner sandbox during a runner-op. Chain instructions (§3.5) take key_hash directly, never plaintext, so the mempool, tx args, storage paths, and event fields don’t observe literal key names. The CLI computes keccak256(account || key_name) locally before submitting any tx. Plaintext names are still visible in deployed manifests (validator-readable) and dictionary-guessable for common names (e.g., OPENAI_API_KEY); see §3.2 (“H(key) honesty”) for the realistic privacy boundary and COW-2442 for the tracked stronger-privacy follow-up.
Keys are namespaced per owning account via the account field. Cross-account access is granted at the policy layer (§3.1.5), not the namespace layer.
SecretKeyRef
The wire-level secret reference carried inside a job spec —secret_refs (the secrets= read set, §5.1/§5.2) and JobPrecondition.secret_ref (§5.5) — as opposed to SecretId, which keys CBSS’s own on-chain storage. Same logical {account, key_hash} pair as SecretId, kept as a distinct named type because it is defined and versioned on the job-spec wire — its canonical bytes are owned by cowboy-protocol-codec::job_spec, the canonical JobSpec encoding (CIP-11 §12.6) — rather than in CBSS storage, but it MUST use the byte-identical key_hash derivation so a SecretKeyRef submitted in a job resolves to the matching SecretId in CBSS storage:
SecretKeyRef is a plain struct on the wire (positional account then key_hash, no enum tag). It carries no plaintext key name, satisfying the “chain instructions take key_hash directly” rule above. On the SDK surface (§5.2) a caller writes either a bare "KEY_NAME" or the explicit cross-account form {"account": ..., "key": ...}; a bare name resolves to the owning account of the actor issuing the runner-op — the actor’s manifest secrets.* entitlements are what authorize the release (§5.1), so the actor’s owning account is the only sound default, and implementations MUST NOT resolve a bare name to any other account (e.g. the transaction submitter). In both forms the client library fills account and computes key_hash locally before the job spec is submitted — the job-spec-layer counterpart of the CLI’s own local SecretId hashing described above — so the wire shape is always the explicit pair: account is required on every SecretKeyRef, and there is no implicit-account variant on the wire.
SecretMetadata
The on-chain record for a secret. Stored at0x04 keyed by SecretId.
SecretVersionHighWaterMark
The durable, monotonic floor for version allocation. Au64 stored at 0x04 keyed by SecretId (byte layout in §3.2). Written by every SetSecret to the version it just allocated; only ever increased, and never lowered or deleted — it is retained across DeleteSecretVersion, DeleteSecret, RotateCommittee force_rekey, and expired override DKGs. It is a first-class committed record, not derived state: an implementer working only from the instruction Effects sections MUST NOT garbage-collect it. The allocation rule and the non-reuse rationale are in §3.2, “Secret version allocation and non-reuse”.
SecretVersion
Immutable per(SecretId, version). Created by SetSecret; deletable by DeleteSecretVersion.
wrapped_dek regardless of ACL size: ACL is purely a runtime gate, not a cryptographic recipient list. Adding or removing an actor from the ACL is a metadata-only update with no chain re-wrap. Storage cost per version is O(1).
SecretPolicy
actors == None resolves at release time to “any actor whose owning account == this secret’s owning account.” actors == Some([...]) is an explicit allowlist and MAY include actors owned by other accounts (cross-account grant; unilateral, no acknowledgment from the recipient account required).
Effective access = (actor’s CIP-6 manifest declares the key under the entitlement matching the release purpose — secrets.read for the secrets=[...] read set, secrets.verify for a verify= MAC check, §5.5) AND (SecretPolicy allows the actor) AND (if tee_required, runner provides a valid CIP-23 attestation). The purpose is derived from the stored JobSpec, precondition-first: a key named by JobPrecondition.secret_ref releases as the verify key (gated by secrets.verify); a key named in secret_refs releases as a read-set key (gated by secrets.read) — unambiguous because the §5.5 overlap rule forbids the same {account, key_hash} in both roles within one job. A key held only under secrets.verify is releasable for the MAC check but MUST NOT be substituted via secrets=[...], and vice-versa.
AccountReleaseKey (default path)
The threshold-shared per-account keypair. Stored at0x04 keyed by Address. Created by the DKG ceremony (§3.6.1); rotated by reshare (§3.6.2). Reused across all of an account’s secrets that don’t specify a per-secret override.
committee_epoch is bookkeeping for the share polynomial. It does not appear in the IBE identity or AEAD AAD — the identity is bound to a per-version wrap_epoch, and the AEAD AAD is that same canonical base plus the per-wrap ephemeral_u (see §3.4.3). PSS preserves MSK across reshare, so a proxy’s current share validly partial-signs any identity ever wrapped under MPK, regardless of the wrap_epoch on the original SecretVersion.
SecretReleaseKey (override path)
Same shape asAccountReleaseKey but scoped to a single (SecretId, version). Stored at 0x04 keyed by that pair. Created lazily when an owner submits SetSecret with committee_override = Some(...). Allows per-secret committee parameters (size, threshold, member preference) for high-value secrets that should not share blast radius with the rest of an account’s secrets.
SecretReleaseKey is single-use in the sense that it is only consumed by its one owning (SecretId, version). Deleting that secret-version (DeleteSecretVersion or DeleteSecret) cascades to the deletion of the corresponding SecretReleaseKey and emits a ShareZeroizationRequested event; share zeroization on committee members is best-effort, not chain-enforced (see DeleteSecret Effects in §3.5).
CbssProxy
The on-chain record of a registered proxy operator. Stored at0x04 keyed by ProxyId.
WrappedDek (IBE envelope)
The encrypted DEK on chain. AES-256-GCM with the IBE-derived key K (see §3.4.3). No public-key envelope per recipient — the IBE construction means there is exactly one ciphertext regardless of ACL size, and the recipient is implicit in the per-version identityI = hash_to_G1( u64_be(chain_id) || canonical(SecretId) || u64_le(version) || u64_le(wrap_epoch) || mpk_g2, domain = "cbss/ibe/v1" ). The encrypted wrap still needs the Boneh-Franklin encryption ephemeral U = r · G2_gen; U is public KEM material, not a recipient key.
U denotes the G2 group element and ephemeral_u = compress(U) denotes its stored 96-byte canonical compressed encoding; formulas over the struct field use ephemeral_u directly. In aad, u64_le(version) is the secret version number (SecretVersion.version, u64) — not this envelope’s 1-byte format-version tag, which is never part of aad or the key derivation. u64_be(chain_id) is the deploying chain’s 8-byte big-endian chain identifier (the same value bound into tx signatures and release requests), and mpk_g2 is the 96-byte canonical compressed encoding of the resolved release key’s master public key MPK (i.e. compress(MPK) — the exact bytes of the mpk field on the AccountReleaseKey/SecretReleaseKey this version is wrapped to). Endianness is deliberate and load-bearing: chain_id is big-endian, version and wrap_epoch are little-endian, and mpk_g2 is raw 96-byte compressed G2. Owner-side encryption and chain-side validation MUST agree byte-for-byte; any change here is wire-breaking.
The serialized WrappedDek format version is 2, carried as a leading 1-byte version tag on every serialization (the first byte of the on-chain codec, the leading field of CBOR/JSON forms). Readers MUST reject any other tag value with a typed error (UnsupportedWrappedDekVersion; §5 error table). The tag is wire framing only: it is NEVER part of the AEAD aad or the key derivation. aad validation is full equality, not suffix equality:
AadMismatch when len(aad) != 172 + 96 (i.e. != 268), when the prefix is not the canonical 172-byte base_aad the chain re-derives for the (chain_id, SecretId, version, wrap_epoch, mpk_g2) tuple, or when the final 96 bytes do not equal ephemeral_u. (The length condition is exact equality — a longer AAD with a valid prefix and valid ephemeral_u suffix is still malformed, per the full-equality rule above.) The chain derives chain_id (its own) and mpk_g2 (from the resolved release key’s committed mpk, never from the envelope), so an envelope wrapped under a substituted mpk' produces a base_aad that does not match the chain’s re-derivation and is rejected here with AadMismatch — the same fail-closed check that gives the IBE identity its release-key binding (§3.4.3).
3.2 Storage layout in 0x04
Privacy scope. Plaintext key names are kept off chain in storage paths and tx args — see §3.5 instructions, all of which take key_hash: Bytes32 directly. Plaintext key names DO appear in:
- CLI / SDK input (the user types
OPENAI_API_KEYintocowboy secrets set). - Actor manifest entitlements (
secrets.read.keys: [...]). Manifests are owner-deployed and validated by the chain; their entitlement keys are visible to validators, indexers, and any node that fetches the manifest. Owners who require unguessable key names should use random suffixes there too (see §5.1). - The runner sandbox during a single runner-op (as the substituted plaintext value’s identifier).
key_hash = keccak256(account || key_name). Validators and chain observers see only key_hash in storage paths, tx args, and event fields. This is dictionary-guessable for common names — see “H(key) honesty” below — but does prevent passive scraping.
The Secrets Whitepaper §2 defines the complete physical key inventory and each family’s creation/deletion lifecycle, including maintenance and co-located TEE rows. The principal secret and evidence keys are:
u64 bytes. Spaces and + above are notation only: literal prefixes concatenate with raw bytes, with the extra literal : only in the evidence-epoch key. The high-water mark key is exactly b"secret_version_hwm:"[19] ‖ account[20] ‖ key_hash[32] (71 bytes). Prior serving-grace epochs are embedded in the release-key records. A tombstone carries deleted_at_block, was_pending and optional public {version, recipient, wrap_epoch} evidence context; it contains no ciphertext. Evidence history has a per-recipient index and one public value per epoch, with the admission/retention rules in §3.5.
Secret version allocation and non-reuse. Every SetSecret — on both the default and override paths — allocates the next version as next_version = max(SecretMetadata.latest_version, secret_version_hwm) + 1, computed with checked u64 addition. Allocation fails closed only when the floor max(SecretMetadata.latest_version, secret_version_hwm) is already u64::MAX, so no successor fits — the instruction is then rejected and no state is written. (Allocating the version number u64::MAX itself succeeds, when that floor is u64::MAX - 1.) On success the chain writes three entries in the same state batch — the new SecretVersion, the updated SecretMetadata, and the durable high-water mark secret_version_hwm:{account_hex}:{H(key)_hex} set to next_version.
The high-water mark is monotonic and irreversible: it is only ever advanced, never lowered or deleted. It survives either delete instruction, account force-rekey and override DKG expiry. SecretMetadata.latest_version selects the greatest surviving entry in active_versions for a versionless read; both DeleteSecretVersion and override ExpireDkgPending recompute it. If no active versions remain, the metadata and account secret-index entry are removed. A selected pending version still fails the pending-release gate. This read-selection rule never frees a version for reuse: the next SetSecret allocates strictly above the durable mark.
Producers MUST take the version from the chain and MUST NOT infer latest + 1 or assume 1 after a “not found”. The node exposes a bounded read-only route, GET /cbss/secret-next-version/{account}/{key_hash} → { "next_version": u64 }, returning the same max(latest_version, secret_version_hwm) + 1 result (failing closed only when that floor is already u64::MAX, so no successor fits). Every SetSecret producer — the cowboy CLI, the SDK, and the dashboard — takes next_version from this route; on the override path the caller-supplied SecretSpecific(SecretId, next_version) MUST equal the chain’s allocated value or the instruction is rejected. A node that does not serve this route is a hard error for a producer, which MUST NOT fall back to a locally guessed version.
Checkpoint and state-restore tooling MUST preserve all CBSS state: the 0x04 actor-storage inventory and the 0x21 CbssPhaseState families described in the Secrets Whitepaper §2. This includes high-water marks, tombstones/evidence context, evidence indexes and epoch values, sweep indexes, reshare-outstanding markers, DKG pending/ceremony records, the reshare sweep cursor, and reshare outbox metadata/chunk pages. Tooling MUST carry each high-water mark verbatim: expired pending override versions are erased without tombstones, so surviving records cannot reconstruct the allocation floor.
The durable allocation floor prevents a recreated (account, key_hash) from reusing an earlier version’s release identity or deletion authority (COW-4074). Access rows are maintained in node’s docs/access/CLIENT-LIMITS.md.
For tee_required secrets, the logical TEE Verifier is 0x05, but node stores the attestation and trusted-key rows physically under 0x04: tee_attestation: plus job ID and runner, and tee_trusted_key_index: plus type length, type bytes and measurement hash. The Secrets Whitepaper §2 gives the value types and lifecycle. The signed-attestation path verifies the job/runner-bound payload against registered trusted keys; CBSS does not accept a proxy-supplied TEE assertion.
Plaintext key names appear only in: (a) the owner’s local CLI, (b) actor manifest entitlements (which are owner-controlled and meant to be public anyway), (c) the runner sandbox during a single runner-op. Chain state never holds them.
H(key) honesty. The statement above — “validators and chain observers see only key_hash, not the key name” — is a true wire-format claim, but it MUST NOT be read as a privacy guarantee. key_hash = keccak256(account || key_name) is dictionary-recoverable: account is a public on-chain address that appears in the very same storage path/tx/event as key_hash, and key_name is, by the §3.1 regex, a short, human-chosen, typically low-entropy string (OPENAI_API_KEY, STRIPE_LIVE_KEY, SLACK_SIGNING_SECRET, and similar). There is no salt, per-account or otherwise, in this hash. A validator — or anyone who knows the account address and holds a wordlist of common key names — can recompute keccak256(account || guess) for each candidate and test it against the on-chain key_hash in negligible time; this is a brute-force dictionary attack against a known, small input space, not a preimage attack against keccak256 itself.
Consequently, key-name privacy under CIP-24 is best-effort, not absolute, and is only meaningfully strong for high-entropy key names (e.g., an owner-appended random suffix, as suggested in point 2 above). Owners who need confidentiality of which service a secret is for SHOULD NOT rely on a common or guessable name being hidden by key_hash alone. What key_hash reliably provides is protection against passive, non-targeted scraping — hiding the literal string from the wire so a chain indexer, log line, or casual observer doesn’t see it verbatim — not resistance to an adversary who is deliberately guessing that account’s key names.
A salted or otherwise name-concealing key_hash construction (a CBSS-keyspace change so key_hash is no longer computable from public inputs plus a guess alone) is tracked as follow-up work in COW-2442 and is out of scope for this CIP.
3.3 Master opcode allocation update
The canonical protocol codec assigns opcodes 68–84 to CBSS. The allocation is:152, 153, and 158 are the canonical assignments. The opcode-uniqueness test guards allocation conflicts.
TEE Verifier support instructions take opcodes 60–63 and serve the logical 0x05 TEE Verifier, whose attestation/trust records are physically stored at 0x04 (§3.2): RegisterTeeTrustedKey (60), RevokeTeeTrustedKey (61), SubmitTeeAttestation (62), and RevokeTeeAttestation (63). These are live in node/types/src/execution.rs (SYS_REGISTER_TEE_TRUSTED_KEY=60 etc.) and are the canonical assignments for these slots.
3.4 Cryptography
3.4.1 Vetted-library mandate
CBSS does not roll its own crypto. The protocol is built as a thin (~few hundred lines) integration layer over published, externally-reviewed primitives:runner/crates/cbss-crypto. Proactive secret sharing (committee resharing) is the one sub-protocol where a vetted Rust library does not yet exist; CBSS implements a thin wrapper following drand’s published resharing protocol. External crypto review is required before launch.
3.4.2 Curve choice: BLS12-381
CBSS uses BLS12-381, not secp256k1 (which Cowboy account keys use). Reasons:- Vetted threshold-encryption libraries exist on BLS12-381. Drand, Lit, the Ethereum-adjacent ecosystem, and Filecoin all ship BLS12-381 threshold tooling at production scale. On secp256k1 the equivalent threshold-encryption tooling does not exist in vetted form; a CBSS implementation on secp256k1 would require hand-rolling the DKG, partial decryption, and NIZK glue — exactly the cryptographic engineering anti-pattern this CIP rejects.
- Pairing-friendly groups make threshold IBE possible with the simple Boneh-Franklin construction: encryption to an “identity” plus a threshold BLS signature on that identity is the IBE decryption key. No bespoke key-switching primitive required.
- Production scale — Ethereum’s beacon chain, Filecoin proofs, Drand all use BLS12-381 at ≥ million-share scale, so library quality is high and corner cases are well-understood.
3.4.3 Construction: threshold IBE (“tlock” applied to secret release)
The construction is standard Boneh-Franklin-style threshold IBE plus threshold BLS signatures, applied with a CBSS-specific identity binding instead of a future-round binding. As in Boneh-Franklin IBE and drandtlock, each owner-side encryption includes a fresh encryption ephemeral r and publishes U = r · G2_gen in the envelope.
Pairing orientation (used throughout this CIP): the BLS12-381 pairing is e: G1 × G2 → GT. I lives in G1; MPK, S_i, and U live in G2; σ and σ_i live in G1. All pairing expressions follow this orientation: G1 element first, G2 element second.
Epoch model: a SecretVersion is bound at SetSecret time to the wrap_epoch = the release key’s committee_epoch at that moment. wrap_epoch is then immutable. Reshare changes the release key’s committee_epoch and rotates the share polynomial, but PSS preserves MSK, so a proxy’s current share validly partial-signs any identity ever derived under any historical wrap_epoch of the same release key. The IBE identity binds chain_id, wrap_epoch, and mpk_g2; the AEAD AAD binds all of those plus the per-wrap ephemeral_u; neither binds the live committee_epoch.
Why mpk_g2 is the right release-key field to bind (soundness of the serve-vs-wrap asymmetry). The base AAD binds the raw mpk_g2 rather than a committee/threshold/VSS commitment because mpk is the only reshare-invariant piece of release-key material: a reshare provably preserves MPK (the chain rejects a reshare whose new_mpk != prior_release_key.mpk; §3.5 RotateCommittee), while committee membership, threshold, and vss_commitments all rotate under PSS. At release time the identity’s wrap_epoch field is historical (read from the immutable SecretVersion) while the mpk_g2 fed to the pairing comes from the current release-key record — this is sound precisely because reshare preserves mpk, so the current mpk equals the wrap-time mpk for every epoch the chain will still serve. The one case where they diverge is a force_rekey re-DKG, which installs a fresh MPK and simultaneously strands every ciphertext bound to the old one (§3.5) — so the mpk_g2 binding never fails a release that would otherwise have succeeded. Binding any rotating field instead (e.g. release_key_material_hash) would strand a secret the moment its wrap-epoch’s material ages out; account secrets have no re-wrap path, so they MUST bind the invariant mpk. (CIP-9, which has an epoch-scoped re-wrap model, binds release_key_material_hash instead — see §9.3. The retired CIP-7 committee path used that binding too; replacement Watchtower does not use CBSS key delivery. The rule that generates all three: bind the strongest release-key commitment the family’s re-wrap model can carry.)
Setup (per AccountReleaseKey or SecretReleaseKey):
- The n CBSS proxies in the committee run a threshold BLS DKG (the devnet implementation uses the pinned Commonware BLS DKG state machine; an equivalent vetted threshold-BLS DKG may be substituted by governance-reviewed implementation update). Round-2 share payloads are recipient-specific and HPKE-encrypted to each recipient’s on-chain
CbssProxy.hpke_pubkey. If timeout or complaint handling excludes a dealer, the daemon finalizes over an explicit qualified dealer set of at leasttmembers. Output: master public keyMPK ∈ G2, Shamir sharess_1 … s_n ∈ Frof the implicit master secret keyMSK ∈ Fr. Thresholdt.MSKis never reified. MPKand the per-proxy commitmentsS_i = s_i · G2 ∈ G2are published on chain inAccountReleaseKeyorSecretReleaseKey.
cowboy-protocol-cbss-crypto (aad.rs, identity.rs) and checked with shared golden vectors. The account-secret layout below does not apply to time-lock release (cbss/tlock/v1, §3.4.6), CIP-9 volume DEKs (cbss/ibe/cip9-volume-dek/v1, §9.3), or the retired CIP-7 content-key family (cip-7-wd-v1). Each active family uses its own specified identity and AAD; replacement Watchtower key delivery is defined in CIP-7 and does not use CBSS.
Storage (per secret-version):
- Let
wrap_epoch= the release key’scommittee_epochat SetSecret/FinalizeSecretVersion time, andmpk_g2 = compress(MPK)the 96-byte compressed master public key of that release key (AccountReleaseKey.mpkon the default path,SecretReleaseKey.mpkon the override path). Define the 172-byte base AAD:base_aad = u64_be(chain_id) || canonical(SecretId) || u64_le(version) || u64_le(wrap_epoch) || mpk_g2wherecanonical(SecretId) = account(20) || key_hash(32)(§3.5), so the fixed-width layout ischain_id(8) ‖ account(20) ‖ key_hash(32) ‖ version(8) ‖ wrap_epoch(8) ‖ mpk_g2(96) = 172bytes. The leadingchain_idis the chain separation binding: a secret wrapped for chain A is rejected by chain B’s validation and its released σ cannot unwrap it. The trailingmpk_g2is the release-key binding: a secret wrapped under a substitutedmpk'hashes to a different identity, so the real committee’s σ can never open it. Owner derives the per-version identity using this canonical encoding (§3.5 SubmitReleaseReceipt):I = hash_to_G1( base_aad, domain = "cbss/ibe/v1" )(RFC 9380 hash-to-curve).I ∈ G1. The owner, the proxies, and the chain all computeIfrom these same inputs in the same byte encoding.Iis derived from the 172-bytebase_aad, not from the extended AEAD AAD below. - Owner samples a fresh encryption ephemeral scalar
r ←$ Frfrom a CSPRNG for every wrap, withr != 0, and computesU = r · G2_gen ∈ G2(96-byte compressed). The owner MUST NOT reuseracross secret versions. Define the AEAD AAD:aad = base_aad || compress(U). Owner computes the IBE encryption key forI:K = HKDF-SHA256( serialize(e(I, MPK)^r), "cbss/ibe/v1" || aad )wheree(I, MPK)^r ∈ GTis equivalentlye(I, r · MPK)ore(r · I, MPK), andserializeis the canonical compressed GT encoding (exactly 288 bytes;blstrsimplementsCompress for Gt). The notationHKDF-SHA256( ikm, info )is normative throughout this CIP and means RFC 5869 HKDF with salt = the ASCII bytes"cbss/ibe/v1"(the same 11-byte domain string as theinfoprefix — not absent, not empty),infoas written, and output length L = 32 bytes. The salt is load-bearing: a derivation withsalt = None/empty produces a differentK, so omitting it makes every wrap undecryptable cross-implementation. - Owner generates a fresh DEK (256 bits, CSPRNG). AES-256-GCM-encrypts the plaintext value with DEK + a fresh nonce + the 172-byte
base_aad(the value envelope bindsbase_aad, not theU-extendedaad— the value envelope’s keyDEKis independent ofU, so it binds only the chain- and release-key-bound base). Uploads the AES-GCM ciphertext to a private CBFS volume →cbfs_pointer. Because the stored value envelope bindsbase_aad, any change tochain_id,canonical(SecretId),version,wrap_epoch, ormpk_g2invalidates the ciphertext of the secret value itself — not merely its DEK wrap. Reconstructing a value envelope with different bound fields requires the plaintext; changing only the DEK wrap is insufficient. - Owner AES-256-GCM-encrypts DEK with
K+ a separate nonce and the 268-byte extendedaadfrom step 4 (base_aad || compress(U)), producingwrapped_dek. Only the DEK wrap binds the extended AAD; the chain re-derives and full-equality-checks all 268 bytes at SetSecret/FinalizeSecretVersion (§3.5). - Owner publishes
wrapped_dek + cbfs_pointer + wrap_epoch + recipient + policyon chain viaSetSecret(default path) orFinalizeSecretVersion(override path; see §3.5).wrapped_dekincludesephemeral_u = compress(U).
- Runner signs the
ReleaseRequestBodydirectly with its runner-registry key (the secp256k1 key registered with0x01Runner Registry, the same key it uses for any other authenticated runner→chain interaction). No runner-binding ephemeral keypair is involved — the IBE construction does not require a runner-bound ciphertext, so there is no per-request runner ephemeral. This is distinct from the owner-chosen Boneh-Franklin encryption ephemeralrin step 4. Both proxies (at PartialSign serve time) and the chain (at SubmitReleaseReceipt validation) verifyrunner_sigagainst the runner registry. - Runner reads the SecretVersion (carrying
wrap_epoch) and the referenced ReleaseKey (carrying current committee +S_is) from chain. Runner sends a release request to t proxies. Each proxy validates ACL/manifest/job-assignment/anti-replay (§3.7). - Each proxy computes the identity exactly as the owner did, using the SecretVersion’s
wrap_epochand the canonical encoding from step 3:I = hash_to_G1( u64_be(chain_id) || canonical(SecretId) || u64_le(version) || u64_le(wrap_epoch) || mpk_g2, domain = "cbss/ibe/v1" ), then its partial signature:σ_i = s_i · I ∈ G1(using the proxy’s current share s_i — PSS guarantees this works regardless of the relationship betweenwrap_epochand the proxy’s current share epoch). - Each proxy returns
σ_ito the runner. Verification is the standard BLS partial-signature check: validatee(σ_i, G2_gen) == e(I, S_i)whereS_i ∈ G2is the proxy’s published VSS commitment. Implementations MUST reject identity/point-at-infinityS_iandσ_ibefore the pairing check; otherwisee(0, G2_gen) == e(I, 0)passes vacuously. No bespoke NIZK required — the BLS pairing equation is its own proof of correctness. - Runner combines t partial signatures via Lagrange interpolation in G1:
σ = Σ λ_i · σ_i = MSK · I ∈ G1(the full threshold BLS signature onI, equivalently the IBE decryption key forI). - Runner reads
wrapped_dek.ephemeral_u(decoding it toU; §3.1 notation), reconstructs the same extendedaad = base_aad || ephemeral_u, and derivesK = HKDF-SHA256( serialize(e(σ, U)), "cbss/ibe/v1" || aad ). By bilinearity,e(σ, U) = e(MSK · I, r · G2_gen) = e(I, G2_gen)^(MSK · r) = e(I, MPK)^r— the sameKthe owner computed at step 4. - Runner AES-256-GCM-decrypts
wrapped_dekwithK→ DEK. - Runner fetches the CBFS object via
cbfs_pointer, AES-256-GCM-decrypts ciphertext with DEK + the 172-bytebase_aad(the value envelope’s AAD; §3.1) → plaintext.
MPK, U, σ, I, S_i, and σ_i; non-canonical compressed encodings; or points outside the prime-order subgroup. blstrs compressed decoding enforces curve/subgroup membership but accepts the identity, so identity rejection is an additional requirement. Implementations MUST also enforce MAX_IBE_AAD_BYTES = 4096 and MAX_IBE_CIPHERTEXT_BYTES = 1 MiB (§4) for every CBSS IBE envelope.
3.4.4 Security properties
- t-of-n threshold: any t honest proxies can complete a release; t-1 cannot reconstruct
σand therefore cannot deriveK. Given public(I, MPK, U), derivingKwithoutσrequires computinge(I, MPK)^r = e(I, G2_gen)^(MSK · r)from(G2_gen, MPK = MSK · G2_gen, U = r · G2_gen), reducing to the co-BDH assumption on BLS12-381. - Owner-chosen
U: a malformed owner-chosenr/Uweakens or bricks only that owner’s own secret. Bindingcompress(U)into the AAD makes post-publish tampering a hard AEAD failure rather than a silent mis-derivation. - No new steering surface:
ris supplied by the secret owner for that owner’s own secret. Proxies and runners treatUas opaque KEM material; reshare/PSS properties are unchanged because the committee still releasesσfor the same identityI. - Per-
(secret_id, version, wrap_epoch)σ: a leakedσdecrypts only the wrapped DEK for that exact identity. Compromising a different secret version requires a separate t-of-n collusion to derive its σ. Reshare does NOT invalidate σ values for prior identities; PSS preservesMSKand hence preserves σ for any identity I. - No bespoke NIZK: BLS partial-signature verification is a single pairing check, covered by
blstrsand the vetted threshold-BLS implementation. - Anti-replay, ACL, audit: enforced at the proxy validation layer (§3.7) and via per-proxy receipts (§3.5). Cryptographic envelope intentionally does NOT bind to a runner ephemeral key — see §3.4.5 for why.
- Reshare: proactive secret sharing on
MSKrotates shares without changingMPK. Stored secrets remain decryptable. In-flight release requests are NOT invalidated by reshare; a proxy’s freshly-resharded share still produces valid partials for any prior-epoch identity. - Insider rotation: the value of reshare is bounding the window during which a specific share-holder’s compromised share is useful. After reshare, that specific s_i is replaced; the attacker must re-compromise the new s_i’ to keep contributing to t-of-n.
3.4.5 Why threshold IBE, not threshold ElGamal with key-switching
The candidate alternative is threshold ElGamal on secp256k1 with a custom “key-switching to runner ephemeral pubkey” construction. Two problems:- No vetted Rust library exists. Threshold ElGamal on secp256k1 requires hand-rolling the DKG, partial-decrypt construction, and Chaum-Pedersen NIZK. Standard cryptographic-engineering hygiene says: don’t roll your own crypto when a vetted equivalent exists. BLS12-381 + threshold IBE has vetted equivalents (Commonware BLS DKG,
tlock,blstrs); secp256k1 + threshold ElGamal does not. - Key-switching to runner ephemeral pubkey was solving a problem we don’t actually have. The runner is the legitimate recipient of DEK; cryptographically binding the ciphertext to a runner-ephemeral pubkey adds protocol complexity without buying additional security beyond what proxy-side anti-replay/ACL already provide. Threshold IBE produces DEK directly to the runner; the runner zeroizes after use; revocation/replay/audit happen at validation time, not in the envelope.
r and publish U = r · G2_gen in WrappedDek. That ephemeral is independent of runner identity; it is the KEM randomness that makes K unrecoverable from public (I, MPK, U).
The threshold-IBE construction is what drand’s tlock ships in production. CBSS uses the same primitive with a CBSS-specific identity binding.
3.4.6 Time-Lock Release mode (height-gated, public identity)
§3.4.3 applied threshold IBE “with a CBSS-specific identity binding instead of a future-round binding.” This subsection re-introduces tlock’s native time-lock binding as a second release mode on the same committee — for consumers (e.g. CIP-34 sealed-bid auctions) that need a key which becomes available at a future height, bound to a caller-chosen identity, and released permissionlessly, independent of any participant. It adds no new key material, no new DKG, and no new trust assumption: it reuses the committee’s existingMPK, shares s_i, and PSS guarantees.
Two modes, disjoint by domain separation.
σ for one mode can never derive the key for the other — the two identity spaces are disjoint in G1 by RFC 9380 domain separation. A tlock identity MUST NOT be used to wrap an account secret (its key is public at target_height); the domain tag enforces this structurally, not by policy.
Identity. For a caller-chosen tag: Bytes (e.g. a CIP-34 request_id) and reveal height target_height: u64:
r ←$ Fr, publishes U = r · G2_gen, and derives K = HKDF-SHA256( serialize(e(I_tlock, MPK)^r), "cbss/tlock/v1" || aad ) against the committee’s existing MPK. (The HKDF salt/info domain is "cbss/tlock/v1", matching the identity domain — load-bearing, as in §3.4.3.)
Scope boundary (normative): time-lock release does NOT inherit the §3.4.3 account-secret AAD. “Identical to §3.4.3” above scopes to the pairing/HKDF construction, not to the AAD contents. Time-lock release keeps its own base AAD —Release — the only structural difference. Once chain height ≥base_aad_tlock = tag ‖ u64_le(target_height)(for CIP-34 sealed bids,tag = request_id, sobase_aad_tlock = request_id(32) ‖ u64_le(reveal_height); CIP-34 §Sealed-Bid), extended toaad = base_aad_tlock ‖ compress(U)for the AEAD. It does not gainchain_idand does not gainmpk_g2; the §3.4.3 chain-separation and release-key bindings apply only to the account-secret family. Base AADs in this CIP are per-family and do not inherit across families (the same rule that keeps CIP-9’s volume path on its own base AAD, §9.3). The tlock/CIP-34 key-substitution exposure is therefore not closed by an AAD change (a submit-time mismatch would arrive too late, and there is no on-chain envelope validation to attach it to); it is addressed by authenticating the committeeMPKat the encryptor against the release key’srelease_key_material_hashbefore sealing, and by an independent committee-side reveal-height check — both tracked as separate workstreams, not as changes to this base AAD. See CIP-34 for the sealed-bid scoping statement.
target_height, each committee proxy computes its partial on the tlock identity with its current share and posts it on chain:
e(σ_i, G2_gen) == e(I_tlock, S_i) (identity/point-at-infinity rejection as in §3.4.3). The accumulated partials are a public chain artifact: anyone reads ≥ t of them, Lagrange-combines σ = Σ λ_i · σ_i = MSK · I_tlock, and derives K = HKDF-SHA256( serialize(e(σ, U)), "cbss/tlock/v1" || aad ). There is no §3.7 validation (no ACL, no job-assignment, no runner-binding, no anti-replay on the reader) — time-lock release is public by construction; the only gate is the height. Single-epoch quorum (consensus-critical, twin of §9a’s MixedEpochReceipts): the partials MUST all be from one committee_epoch — the Lagrange x-coordinates are proxy positions in that epoch’s committee, so a cross-epoch combine yields the wrong σ. The partial set records the committee_epoch of its first accepted partial as its quorum_epoch; if a reshare advances the committee while the set is below threshold, the set is RESET to the new epoch (stale, now-uncombinable partials discarded) so collection can still complete. A combiner MUST combine only same-quorum_epoch partials; if the live committee has advanced past a threshold-reached set’s quorum_epoch, the release is treated as not-yet-releasable (deterministic) rather than silently mis-combined — the consumer retries or hits its grace fallback (CIP-34 §Sealed-Bid).
Registration + bounded committee work. A tlock release is registered on chain (RegisterTlockRelease { tag, target_height }, §3.5) so the committee knows what to sign and when. Registration is fee-bearing and bounded: a per-block cap on new registrations, and a registered request (plus its posted partials) is GC’d after target_height + TLOCK_RETENTION (§4). A proxy MUST reject SubmitTlockRelease while head < target_height (premature reveal) and for an unregistered (tag, target_height).
Reuse + reshare. A proxy’s current share s_i validly partial-signs any identity under MPK, tlock identities included (PSS preserves MSK; §3.4.4). Reshare does not invalidate a registered tlock request — a freshly-resharded share still produces a valid partial for I_tlock.
Liveness is the consumer’s to handle. The committee-liveness assumption is identical to secret release. If fewer than t partials are posted by target_height + GRACE, the consumer owns the fallback — e.g. CIP-34 cancels the auction (CancelledGraceExpired) at reveal_height + AUCTION_GRACE (CIP-34 §Sealed-Bid); as built it does not re-open a plaintext OPEN round. CBSS itself adds no time-lock fallback; it exposes the height-gated release and the consumer composes the timeout.
Security delta vs §3.4.3. Confidentiality holds only until target_height (by design); before it, the co-BDH argument of §3.4.4 applies unchanged (t-1 proxies cannot derive σ). Because the identity binds tag, the release for one tag reveals nothing about another tag at the same height — so one auction’s reveal cannot expose another auction’s still-sealed bids. The public-at-height property is exactly why the domain separation from account-secret identities is mandatory.
3.5 New system instructions
Each instruction’sSender column gives who is authorized to submit it. Gas costs in §4.1.
SetSecret (opcode 68)
The instruction is shape-asymmetric across the two recipient types because in the override path the recipientMPK does not exist at SetSecret time — it must be produced by a fresh DKG ceremony first. The default path stays one-shot; the override path is two-phase, with FinalizeSecretVersion (opcode 78) attaching the ciphertext after DKG finalizes.
-
Default path (
recipient = Account(sender)): must carrycbfs_pointer + wrap_epoch + wrapped_dek(DEK encrypted under the IBE key derived from the account’s existingMPK; nonces,ephemeral_u, and AAD are insideWrappedDekand the CIP-9 CBFS envelope). The instruction is rejected withMissingAccountReleaseKeyif the sender has noAccountReleaseKeyon file (thecowboyCLI auto-issuesRequestAccountDkgon first use). Allocatesversion = max(SecretMetadata.latest_version, secret_version_hwm) + 1(checkedu64add; fails closed only when that floor is alreadyu64::MAX), createsSecretVersion(version)underSecretId(sender, key)withpending = false, and advances the durable high-water mark to that version — see “Secret version allocation and non-reuse” in §3.2. EmitsSecretCreated. -
Override path (
recipient = SecretSpecific(SecretId, next_version)): must NOT carrycbfs_pointer / wrap_epoch / wrapped_dek. Instead carriescommittee_override. Effects:- Creates
SecretVersion(version = next_version)withpending = true,wrap_epoch = None,cbfs_pointer = None,wrapped_dek = None, wherenext_versionMUST equal the chain’s allocationmax(SecretMetadata.latest_version, secret_version_hwm) + 1(the instruction is rejected otherwise; see “Secret version allocation and non-reuse” in §3.2). Advances the durable high-water mark tonext_version. (The §3.1 invariant requires all three Option fields are None on a pending version.)wrap_epochis populated byFinalizeSecretVersiononceMPKexists. - Locks
DKG_BONDfrom the sender, VRF-selects an n-proxy committee per the override spec, and writes aDkgPending(SecretSpecific(SecretId, next_version), committee, deadline, bond_amount, requester = sender)record. - Off-chain DKG runs (§3.6.1). On commit, the committee submits
RotateCommittee { scope: SecretSpecific(...), new_committee_epoch: 1, ... }, which writes the newSecretReleaseKeyand exposesMPKon chain. - Owner reads
MPK + committee_epochfrom chain, setswrap_epoch = committee_epoch(= 1 here), samples freshr != 0, computesU = r · G2_gen, derivesK = HKDF(serialize(e(I, MPK)^r), "cbss/ibe/v1" || aad)withaad = u64_be(chain_id) || canonical(SecretId) || u64_le(version) || u64_le(wrap_epoch) || mpk_g2 || compress(U), encrypts DEK toK, uploads CBFS ciphertext, and submitsFinalizeSecretVersion(opcode 78) to attachcbfs_pointer + wrapped_dek + wrap_epochand clearpending. - Until step 4 lands, release attempts against this version fail closed with
SecretPending.
- Creates
- Default path:
wrapped_dek.version == 2(§3.1; rejects withUnsupportedWrappedDekVersionotherwise);wrap_epoch == account_release_key.committee_epochat SetSecret block;wrapped_dek.ephemeral_uis the canonical compressed encoding of a non-identity G2 point in the prime-order subgroup (subgroup membership REQUIRED; §3.4.3); andwrapped_dek.aad == u64_be(chain_id) || canonical(SecretId) || u64_le(version) || u64_le(wrap_epoch) || mpk_g2 || wrapped_dek.ephemeral_u, wherechain_idis this chain’s identifier andmpk_g2 = compress(account_release_key.mpk)— both taken from committed state loaded one step above (theAccountReleaseKeyalready in hand for thewrap_epochcheck), never from the envelope. Rejects withWrapEpochMismatch/AadMismatchotherwise. Awrapped_deksealed under a substitutedmpk'(or for another chain) therefore fails this full-equality check withAadMismatch— the chain-side half of the release-key binding (§3.4.3). - Override path:
committee_override.nandtwithin(MIN_OVERRIDE_T, MAX_OVERRIDE_N);preferred_proxies(if Some) all eligible. - Both paths:
key_hashis a well-formed 32-byte value; quotas not exceeded. (PlaintextKeyNameregex enforcement is CLI/SDK-side, since the chain never sees plaintext.)
SecretCreated { id, version, recipient_kind, pending: bool, ... }.
FinalizeSecretVersion (opcode 78)
Attaches ciphertext to apending = true SecretVersion after its DKG ceremony has committed. Used only on the override path.
SecretVersionexists, haspending = true, andrecipient = SecretSpecific(_, version).- The corresponding
SecretReleaseKeyis committed (exists on chain) and itscommittee_epoch ≥ 1. wrap_epoch == secret_release_key.committee_epochat FinalizeSecretVersion block (no resharing in between, OR the owner explicitly accepts re-wrapping under the new epoch).wrapped_dek.version == 2(§3.1); rejects withUnsupportedWrappedDekVersionotherwise.wrapped_dek.ephemeral_uis the canonical compressed encoding of a non-identity G2 point in the prime-order subgroup (subgroup membership REQUIRED; §3.4.3).wrapped_dek.aad == u64_be(chain_id) || canonical(SecretId) || u64_le(version) || u64_le(wrap_epoch) || mpk_g2 || wrapped_dek.ephemeral_u, withchain_idthis chain’s identifier andmpk_g2 = compress(secret_release_key.mpk)derived from committed state (the override path’sSecretReleaseKey), never from the envelope; a substitutedmpk'or wrong-chain wrap fails withAadMismatch.
cbfs_pointer, wrap_epoch, wrapped_dek on the SecretVersion and clears pending. From this point release proceeds normally.
Emits SecretVersionFinalized { id, version }.
An installed but unfinalized version remains pending and consumes a quota slot until finalized or deleted. If its key has not been installed, ExpireDkgPending is the cancellation path (§3.5).
UpdateSecretPolicy (opcode 69)
SecretVersion. No re-wrap is required — the cryptographic recipient is the account (or per-secret override key), not the individual ACL actors. ACL changes are pure metadata and propagate atomically to all subsequent release attempts. ACL semantics on update: replace, not merge (the new policy fully supersedes the old).
This is one of the operational wins of per-account anchoring vs per-actor: granting or revoking an actor’s access is a single chain tx with no proxy round-trips, no re-encryption, no chain-storage fan-out.
Emits SecretPolicyUpdated.
DeleteSecretVersion (opcode 70)
SecretVersionTombstone with deleted_at_block, was_pending and public evidence context (§3.2). For an installed override it retains public committee evidence, then removes the dedicated SecretReleaseKey, its rksweep: entry and its cbss_reshare_outstanding: marker. Shared account release keys and their sweep/reshare state survive, as do per-proxy reverse-index entries, retained evidence and the high-water mark. Metadata and the account secret index follow the release-selection rule in §3.2. Removing the version removes its on-chain CBFS pointer; this handler does not issue a CBFS object-drop instruction.
A pending override whose dedicated key is not yet installed is rejected: the owner cancels it through ExpireDkgPending. An installed but unfinalized override can be deleted; its tombstone has was_pending = true and no evidence context because it was never releasable. Subsequent release authorization rejects a deleted version.
The chain emits cbss.secret.version_deleted with account[20] ‖ key_hash[32] ‖ version u64 BE. For override keys it also emits cbss.share.zeroization_requested: exactly 68 bytes, account[20] ‖ key_hash[32] ‖ version u64 BE ‖ epoch u64 BE. Epochs are deduplicated and sorted; whole-secret deletion visits versions in ascending order, giving version-then-epoch ordering. At most 256 zeroization hints are emitted per transaction. Hints are best-effort: the committed tombstone is authoritative even when further hints are omitted. The daemon treats a finalized tombstone as authoritative revocation of that version. Tombstone reconciliation selects local shares for erasure only when their complete public epoch matches the committed observation; a was_pending tombstone supplies no erasure epochs, and unmatched material remains untouched. A direct zeroization hint separately targets a local share with the exact secret-version scope and epoch. Revocation therefore does not imply unconditional erasure of pending or unmatched material, and chain execution cannot prove that every operator erased its copies.
DeleteSecret (opcode 71)
SecretMetadata and its account secret-index entry. If any version cannot be deleted, the whole transaction rolls back, including prior version writes and events. Shared account keys and the durable high-water mark survive. The chain emits the bounded zeroization hints above and cbss.secret.deleted with account[20] ‖ key_hash[32] ‖ latest_version u64 BE, using the metadata value read for the deletion; it does not emit a separate cbss.secret.version_deleted for each version.
RegisterCbssProxy (opcode 72)
stake_amount CBY in the proxy’s escrow, mints a fresh ProxyId, writes CbssProxy with eligible_after = current_block + PROXY_SOAK_PERIOD. The proxy is not VRF-eligible until soak completes.
DeregisterCbssProxy (opcode 73)
unbond_at = current_block. Stake remains locked for UNBOND_COOLDOWN. If the proxy is currently a member of any AccountReleaseKey.committee or SecretReleaseKey.committee, RotateCommittee is enqueued for each affected release key. After cooldown elapses with no slashing case, stake is returned and the CbssProxy record is purged.
RotateCommittee (opcode 74)
new_committee_sigs, not from being the lone submitter.
Effects: verifies release-key material and threshold committee signatures. A completed rotation installs the targeted AccountReleaseKey or SecretReleaseKey with its new committee epoch. On normal reshare, the previous committee and commitments are appended to the release key’s embedded prior_committee_epochs, with expires_at = current_block + RESHARE_GRACE_BLOCKS; entries with expires_at < current_block are pruned first. If the vector would exceed MAX_PRIOR_COMMITTEE_EPOCHS (4), rotation is rejected until enough prior epochs expire. Account force-rekey clears serving grace; overrides reject force-rekey. Rotated-out public material is separately retained for slashing under the evidence rules below.
For epoch selection, a receipt may use a retained prior committee only when committee_epoch_at_serve matches its entry in prior_committee_epochs and submission is at or before expires_at; an expired or missing epoch is rejected. Old committee members MUST zeroize their old shares before signing the quorum attestation; failure to do so is not directly slashable but the signing under false attestation is future-evidence for a leak case. Existing SecretVersion records keep their original wrap_epoch (immutable per version); the IBE identity for those versions is unchanged. New SetSecret calls after this RotateCommittee bind to the new committee_epoch as their wrap_epoch.
mpk is preserved across reshare (proactive secret sharing); only the share polynomial changes. This is true for both account-scoped and secret-scoped keys.
This instruction is also used to commit the result of an initial DKG ceremony triggered by RequestAccountDkg or by the override path of SetSecret. In that case, prior_committee_sigs is empty (there is no prior committee to attest zeroization from), but new_committee_sigs is still required — at least t signatures from members of the freshly-DKG’d new committee over the canonical DKG-commit payload (see the field comments in the struct above). This is what authenticates the initial DKG output; without it, a single nominated proxy could commit an arbitrary (MPK, S_i) set.
SlashCbssProxy (opcode 75)
On-chain-provable fault classes are limited to those that admit objective, on-chain evidence. Liveness is excluded from automatic slashing entirely — packet-delivery non-receipt is fundamentally not provable on chain (a malicious or broken runner can submit a valid signed request without ever delivering it to the proxy; the proxy cannot prove a network negative). Liveness instead drives a health score (§4.4) that affects VRF selection weight, and persistent extreme degradation triggers governance review (CIP-12 Tier 1ForcedDeregisterCbssProxy). The on-chain SubmitLivenessChallenge / LivenessChallengeResponse remain as the audit trail that feeds the health score, but they do not by themselves cause stake loss.
PlaintextLeak is also removed from protocol-level slashing because its evidence (“here is the recovered DEK plus t-1 partials”) would publish the secret on chain. Off-chain disclosure → governance → ForcedDeregisterCbssProxy is the channel.
CbssProxy.stake, sets suspended = true pending governance review.
Invalid-partial evidence admission. The V4 evidence contains the runner-signed request and proxy-signed blinded response. Let R = request_block, S = served_at_block and anchor = clamp(S, R, R + REQUEST_FRESHNESS_BLOCKS), with REQUEST_FRESHNESS_BLOCKS = 384. Submission is admissible from R through anchor + CBSS_EVIDENCE_WINDOW_BLOCKS (512 blocks), inclusive. Height addition saturates at u64::MAX. The verifier authenticates the request, blinding authorization, proxy response and committee position before accepting a fault. An authenticated stamp outside [R, R + 384], in either direction, is itself a fault even if the partial’s pairing is valid. Inside that range only an invalid/malformed partial is slashable; a valid pairing with an in-range stamp is never slashable, regardless of submission time.
For a deleted version, the verifier uses its public tombstone context and requires R ≤ deleted_at_block and was_pending == false. If the serving epoch is no longer current, the verifier point-reads its retained public ReleaseKeyEvidenceEpoch, requires R ≤ last_served_at, and also requires submission at or before last_served_at + 512. Both deletion and historical-epoch eligibility compare the runner-signed request block, never the accused proxy’s stamp. Retention ends at the committed epoch cutoff plus 512; clamping the stamp does not extend it.
Rotation and override deletion retain a sorted per-recipient epoch index plus one public value per epoch, at most 64 epochs. Each value contains the MPK, ordered committee, threshold, epoch, VSS commitments and last_served_at. History pruning removes expired values and rewrites the index when history is next retained. A live override key has at most 28 indexed historical epochs before deletion because force-rekey is rejected for overrides and serving grace permits only four prior epochs within each inclusive 100-block window. The delete cascade also snapshots the current epoch, so post-delete evidence can contain 29 epochs. Evidence history never authorizes serving. Equivocation verification authenticates the request and two conflicting proxy responses directly and needs no release-key history.
SubmitLivenessChallenge (opcode 79)
Anchors on chain that a runner experienced a non-response from a proxy. Feeds the proxy’s health score; does NOT by itself trigger slashing.challenged_at is NOT a tx arg — the chain sets it to the inclusion block height when the instruction is processed. This prevents future-dating or backdating attacks on the response-deadline timing.
Request signer: the runner identified by runner_request.body.context.runner_id.
Effects: authenticates the request and challenge, then validates the accepted receipt snapshot:
- The request’s
chain_idmatches this chain. The V4 request signature and outerchallenge_sigeach recover the runner address named in the request. request_block + MIN_CHALLENGE_DELAY_BLOCKS ≤ submission_block ≤ request_block + LIVENESS_CHALLENGE_MAX_AGE_BLOCKS, with saturating height addition. The handler setschallenged_at = submission_block.- An accepted
release_receipt:{release_id}set exists for the exact request hash and serve epoch, with a nonzero threshold and at least that many receipts and eligible committee members. The challenged proxy belongs to that stored committee snapshot. Every receipt matches the request hash and epoch, and its proxy index matches its committee position; indices are strictly increasing and proxy IDs are unique. This authenticated attempt proof remains valid across later reshares; admission does not re-evaluate current ACL, manifest, job assignment orSecretVersionstate. - Per-release liveness dedup. No
liveness_challenge_index:{release_id}:{proxy_id}entry exists. While the index exists, another challenge for that pair is rejected. A response to a pending challenge and expiry of a pending challenge delete the index; the retained challenge record alone is not a permanent dedup guard. A challenge resolved at admission keeps its index. - If the accepted receipt set already contains a receipt from the challenged proxy, the challenge is immediately
Resolvedand the runner forfeits the bond to that proxy.
LIVENESS_CHALLENGE_BOND from the runner’s account and writes liveness_challenge_index:{release_id}:{proxy_id} → challenge_id so duplicate challenges are rejected while that index remains. If a receipt for (release_id, proxy_id) already exists, the handler writes LivenessChallenge { id, runner_request, bond_amount, challenged_at = submission_block, status: Resolved, deadline = submission_block } and transfers the bond to the challenged proxy. Otherwise it writes LivenessChallenge { id, runner_request, bond_amount, challenged_at = submission_block, status: Pending, deadline = submission_block + LIVENESS_RESPONSE_BLOCKS }. The proxy has until deadline to dispute.
Admission-time receipt resolution. Because MIN_CHALLENGE_DELAY_BLOCKS is strictly greater than REQUEST_FRESHNESS_BLOCKS, every receipt that can pass receipt freshness validation is already either on chain or impossible to submit by the time a liveness challenge is admissible. Runners are expected to check release_receipt:{release_id}.entries[proxy_id] before challenging. If they challenge despite an existing receipt, the chain resolves the challenge immediately and transfers the bond to the proxy. The receipt itself does NOT need to be fresh per REQUEST_FRESHNESS_BLOCKS for this purpose — persistent release_receipt state is the authoritative record of “did the proxy ever submit a paid receipt for this release_id.” Resolved is therefore an admission-time state, not a response-time state.
If a proxy served but failed to submit a receipt before the receipt freshness window closed, it cannot later create chain-verifiable receipt evidence. Its only on-chain response path is an Unverifiable reason (typically NeverReceived or structured local-failure evidence), which is health-neutral but does not produce a positive liveness signal.
After deadline, a still-Pending challenge transitions to Unanswered, refunds the bond to the runner, and applies the standard negative health-score decrement to the proxy. No automatic slash follows. The challenge is recorded in the proxy’s audit history. Persistent unanswered challenges may justify a governance proposal under CIP-12.
LivenessChallengeResponse (opcode 80)
A proxy’s structured response to a liveness challenge. Records chain-unverifiable response evidence; does NOT by itself prevent governance action.proxy_sig over the exact byte layout above against CbssProxy.pubkey, then evaluates evidence per reason. The chain transitions the challenge to a terminal state with bond disposition that matches the chain’s view of who was right:
Resolved— not produced byLivenessChallengeResponse; produced only by the admission-time receipt check inSubmitLivenessChallenge. Chain dereferencesrelease_receipt:{release_id}and confirms the entry exists withproxy_idmatching. Health-score impact on the proxy: small positive (proved liveness via receipt). Bond disposition: forfeited to the challenged proxy.Unverifiable— proxy submitsLivenessChallengeResponsewithInvalidRequest/RateLimited/NeverReceivedbefore deadline. Chain cannot re-derive any of these. Bond → refunded to the runner (the runner submitted a good-faith challenge; whether the proxy’s response is honest is unknowable on-chain). Health-score impact on the proxy: NEUTRAL — no automatic decrement. Pattern detection lives in off-chain governance.Unanswered— proxy submits no response bydeadline. The runner was right — the proxy is genuinely silent. Bond → refunded to the runner. Health-score impact on the proxy: standard negative decrement. Persistent unanswered patterns flag the proxy for governance review per §4.4.
Resolved via an existing receipt); in all other cases (proxy went silent or proxy gave an unverifiable response) the runner gets the bond back.
Anti-spam friction. A naive runner could still try to challenge proxies before they’ve had time to submit their receipts. To prevent this, SubmitLivenessChallenge enforces body.request_block + MIN_CHALLENGE_DELAY_BLOCKS ≤ submission_block (see §4.2). Challenges submitted too soon after the original request are rejected with ChallengePremature. The default delay is REQUEST_FRESHNESS_BLOCKS + 32 (~12 min), giving honest proxies a generous window to either serve + submit a receipt (which then resolves any later challenge at admission) or fail loudly.
Only the admission-time receipt check is chain-verifiable and resolves to Resolved. Unverifiable captures “the proxy responded with chain-unverifiable evidence” without penalizing either party. Repeated Unverifiable patterns by the same proxy escalate via off-chain governance, not protocol-level slashing or health-score automation.
SubmitReleaseReceipt (opcode 76)
Per-proxy submission. The V4 receipt carries the runner’s signed blinded request and the proxy’s signed blinded response. The chain authenticates both signatures, verifies the blinding authorization and checkse(tau, G2_gen) == e(blinded_identity, S_i) against the public share derived from the committed VSS polynomial and the proxy’s committee index. The receipt does not publish the unblinded partial sigma_i.
tee_attested is not a receipt argument. release_id omits request_block, while request_hash binds the complete encoded V4 body.
Canonical domain-separated signature payloads. All sigs use a domain-string prefix to prevent cross-protocol replay; implementations MUST sign exactly these byte layouts:
base_aad := u64_be(chain_id) || canonical(SecretId) || u64_le(version) || u64_le(wrap_epoch) || mpk_g2
I := hash_to_G1( base_aad, domain = "cbss/ibe/v1" )
The owner (at SetSecret/FinalizeSecretVersion), the proxy (at PartialSign), and the chain (at SubmitReleaseReceipt pairing-check) all compute I from the same 172-byte base_aad in the same encoding. Here chain_id is the chain’s own identifier and mpk_g2 = compress(MPK) is taken from the release key the chain resolves for the pairing check (§3.6.2 reshare-aware lookup) — not from any client input — so the identity all parties hash is bound to the exact release key. AAD on the AES-GCM wrapped_dek envelope extends the same canonical encoding with the Boneh-Franklin encryption ephemeral: aad = base_aad || wrapped_dek.ephemeral_u. The chain and runner MUST reject the envelope with AadMismatch unless this full equality holds; len(aad) != 172 + 96 (i.e. != 268) is malformed AAD.
Response signer: the operator of response.proxy_id; the response signature must recover that registered operator address.
Chain validation:
- Authenticate
runner_request.runner_signatureagainst the runner address in the V4 context; recomputerequest_hashandrelease_idas above. - Check the existing
(release_id, response.proxy_id)receipt. An exact replay of an accepted receipt returns success, including after freshness expires; a conflicting receipt is rejected. - For a new receipt, require
request_block ≤ submission_block ≤ request_block + REQUEST_FRESHNESS_BLOCKS, using saturating addition, andrequest_block ≤ served_at_block ≤ submission_block. The response hash must match the request, andcommittee_epoch_at_servemust equal the runner’sserve_epoch. - Evaluate current release authorization, including the version, policy, manifest and job/session authority. Authenticate the proxy response and resolve the serving epoch’s release key and committee. A retained prior epoch is usable only through its inclusive
expires_atboundary (§3.5). - Require the response’s nonzero
proxy_indexto equal the proxy’s one-based position in the resolved committee. Verify the runner’s blinding authorization against the committedwrap_epochand MPK, deriveS_ifrom the VSS polynomial at that index, and check the blinded partial’s pairing. Malformed points and failed proofs are rejected. - Require every receipt in the set to share the same request hash, epoch, threshold and ordered eligible committee snapshot. A new receipt is rejected once the threshold has already been reached.
- TEE eligibility is chain-derived. The release evaluator checks the required TEE authority in the logical TEE Verifier’s records, physically stored at
0x04(§3.2). It checks a runner’s measurement binding and, where the signed-attestation path applies,tee_attestation:+job_id || runner_id. The proxy cannot supply atee_attestedoverride.
ReleaseReceiptSet with its request/committee snapshot and sort entries by proxy index. Charge the release receipt to the actor and credit the submitting proxy under the payment rules below.
Overdraft / delinquency. If actor.account.balance < RELEASE_FEE_PER_RECEIPT, the receipt is still recorded and the proxy is still credited, with the actor’s release-payment balance going negative (capped at MAX_RELEASE_OVERDRAFT, governance-tunable, default 16 × RELEASE_FEE_PER_RECEIPT). Implementations whose base account codec cannot represent negative balances MAY store the negative portion in a separate release-debt ledger; the Cowboy node currently stores this as CBSS ActorReleaseDebt. The actor account transitions to delinquent status, which suspends new job dispatching to that actor until the negative balance is repaid. On Cowboy, JobSubmit rejects outstanding ActorReleaseDebt; FundActor repays that debt before any newly funded balance becomes spendable and emits cbss.actor.debt_repaid when repayment is applied. If overdraft would exceed MAX_RELEASE_OVERDRAFT, the receipt is rejected with ActorAccountInsufficient and no payment occurs — proxies that observe this on chain SHOULD stop accepting PartialSign requests from this actor until repayment is observed. This separates “honest proxy was paid for honest work” (always succeeds within overdraft cap) from “actor pays its bills” (enforced by delinquency suspension + cap).
Each receipt emits one event:
SecretReleased (quorum event) is emitted when the t-th receipt for a given release_id lands, carrying the actual proxy_set: [ProxyId; t] accumulated in release_receipt:{release_id}:
t from the referenced release key, so the threshold-cross detection is deterministic.
RequestAccountDkg (opcode 77)
SetSecret if no AccountReleaseKey exists yet.
Effects: Locks DKG_BOND from the requesting account. VRF-selects n = DEFAULT_N proxies from the eligible set, weighted by stake × health_score (see §4.4). Writes a DkgPending { scope, force_rekey, committee, threshold, deadline = current_block + DKG_FINALIZE_BLOCKS, bond_amount, requester } record at dkg_pending:{scope_serialized} (the record persists force_rekey and threshold so the off-chain ceremony and any later RotateCommittee see the quorum size and re-key intent). Off-chain, the selected committee runs the DKG ceremony (§3.6.1) and posts the result via RotateCommittee with scope = Account(sender) and new_committee_epoch = 1. On successful RotateCommittee, the chain refunds DKG_BOND in full and clears the DkgPending record.
If the DKG ceremony fails to commit by deadline, the DkgPending record stays on chain until expired by ExpireDkgPending (see below).
Both RequestAccountDkg and the governance path below emit a cbss.dkg.requested event with payload DkgRequestedEventData { scope, epoch, force_rekey, threshold, committee_metadata } — epoch is the ceremony epoch, committee_metadata carries the selected proxies’ connection details (id / operator / network address / HPKE + DKG public keys), and force_rekey signals a re-key vs first bootstrap. Note the event does not carry deadline (a consumer needing the ceremony deadline reads the DkgPending record, not the event). The off-chain committee daemon consumes this event to run the ceremony (§3.6.1).
GovRequestSystemDkg (opcode 158, provisional)
GOVERNANCE_SYSTEM_ACTOR (0x09) only — any other tx.from is rejected with Unauthorized.
Invocation (how a 0x09-origin tx arises). 0x09 is itself keyless, so — like every 0x09-authorized instruction (UpdateSettlementConfig, SetGovParam) — a GovRequestSystemDkg tx is not externally signed. It is produced by CIP-12 governance-proposal enactment: a passed proposal’s payload is applied deterministically by 0x09 with tx.from = 0x09 (CIP-12 §“payloads are executed deterministically by 0x09”). This rides the CIP-12 ProposalPayloadKind (SystemDkgBootstrap { scope, force_rekey }) that ExecuteProposal maps to this instruction.
Motivation. RequestAccountDkg derives its scope from sender (scope = Account(sender)) and is authorized by the account’s own signature. A keyless system actor — one whose address holds native ledger/registry state but has no private key and therefore can never be tx.from — cannot use it. The concrete instance is the CIP-34 sealed-bid INTENT_SETTLEMENT committee (Account(0x14)): sealed-bid reveal (CIP-34 §Sealed-Bid) decrypts every bid against 0x14’s CBSS committee, so that committee’s AccountReleaseKey (committee + MPK + shares) MUST exist before any sealed auction can clear — but 0x14 cannot self-issue RequestAccountDkg. GovRequestSystemDkg is the only path that establishes a committee for such a scope, authorized by governance rather than by an owner signature.
Effects. Mirrors RequestAccountDkg exactly, with three differences: (i) tx.from MUST equal GOVERNANCE_SYSTEM_ACTOR; (ii) the scope is taken from args.scope rather than from sender; (iii) no DKG_BOND is locked (bond_amount = 0) — governance has no user account to debit, and the permissionless-cleanup incentive is unchanged because ExpireDkgPending on a zero-bond pending simply refunds/slashes nothing. Concretely, on a valid request the chain: VRF-selects n = DEFAULT_N proxies weighted by stake × health_score; writes DkgPending { scope, force_rekey, committee, threshold = DEFAULT_T, deadline = current_block + DKG_FINALIZE_BLOCKS, bond_amount = 0, requester } at dkg_pending:{scope_serialized} (requester is the scope’s account, used only for the — here zero — bond accounting); persists the COW-1057 ceremony record so a sabotaging dealer stays slashable after the pending clears; and emits cbss.dkg.requested. Off-chain, the selected committee runs the DKG (§3.6.1) and commits via RotateCommittee with new_committee_epoch = 1 and empty prior_committee_sigs — the same initial-commit path RequestAccountDkg uses. ExpireDkgPending (opcode 81) applies unchanged if the ceremony misses deadline.
Validation. The request is rejected with InvalidData if (a) a DkgPending already exists for scope (dedup — one ceremony in flight per scope); (b) a release key already exists for scope and force_rekey == false; or (c) no release key exists for scope and force_rekey == true. In other words force_rekey MUST be set iff the scope already has a live committee — first-time bootstrap uses force_rekey = false, re-establishment uses force_rekey = true.
Scope MUST be a keyless system actor (least-privilege). scope MUST resolve to a keyless system-actor address — i.e. an address in the reserved system-actor range (< 0x20, per WP §9.1) that has no private key, such as Account(0x14). The handler MUST reject any other scope (e.g. Account(<user>), or a SecretSpecific under a user account) with Unauthorized. Rationale: without this bound, a passed proposal could GovRequestSystemDkg { scope: Account(<user>), force_rekey: true } and re-DKG a normal user’s account committee — installing a fresh MPK that permanently strands every secret that user ever wrapped. Keyed accounts never need this path (they self-issue RequestAccountDkg), so restricting scope to keyless system actors costs nothing and removes an unnecessary governance power over user secrets. (This is a validation the chain can check locally, unlike the force_rekey-during-open-auction hazard below.)
force_rekey is destructive — a safety MUST. A force_rekey re-DKG produces a fresh, independent MPK (via the RotateCommittee initial-commit path, new_committee_epoch = 1), not a reshare of the existing MSK. Every ciphertext bound to the old MPK becomes permanently undecryptable — see §10.7 (MPK is preserved only on reshare, never on a fresh DKG). For the 0x14 sealed-auction committee this means any in-flight sealed bid (a WrappedDek encrypted to the old MPK under identity = request_id) can no longer be revealed, and its auction cancels with no funds moved (both cancel terminals delete state only). The exact terminal depends on the new committee: if it posts partials for the pre-existing tlock request, reveal_auction combines σ under the new MPK, every old-MPK bid fails AEAD, and the auction ends CancelledNoValidBid at reveal_height; if it posts nothing, the reveal stays TlockNotReleased and cancels CancelledGraceExpired after reveal_height + AUCTION_GRACE (CIP-34 §Sealed-Bid). Either way the bids are stranded. Therefore governance MUST NOT force_rekey the 0x14 committee while any sealed auction is open (i.e. any OpenAuction whose reveal_height + AUCTION_GRACE has not passed); doing so silently cancels those auctions. Routine committee refresh that must preserve decryptability MUST use RequestReshare (opcode 83), which rotates the share polynomial while holding MPK fixed (§10.7); force_rekey is reserved for deliberate key replacement (e.g. a disclosed-MSK incident) where stranding old ciphertext is the intent. The chain cannot itself observe cross-subsystem auction state, so this is a governance-procedure invariant, not an on-chain check.
ExpireDkgPending (opcode 81)
Permissionless cleanup of an expiredDkgPending record. Settles the bond and emits a public failure event so the requester can retry.
dkg_pending:{scope_serialized}exists.current_block >= DkgPending.deadline, or the sender is the requester cancelling before the deadline.- No
RotateCommitteefor this scope has landed (i.e., the DKG didn’t complete in some other path).
dkg_pending: for the scope. An override’s pending SecretVersion is erased without a tombstone; metadata and the account secret index follow §3.2’s selection rule, while the high-water mark is retained. The ceremony record survives for sabotage evidence. An outstanding-reshare marker is removed only if its fingerprint binds it to this exact pending ceremony; unrelated reshare state survives. The chain emits cbss.dkg.expired with the canonical binary ReleaseKeyScope.
The requester may retry by submitting a fresh RequestAccountDkg (default path) or SetSecret with committee_override (override path). VRF re-selects a committee — likely overlapping but possibly different given health-score and stake updates.
Per-secret DKG (override path) is initiated by SetSecret itself with committee_override; there is no separate instruction for it. The same DkgPending record is created (keyed by (SecretId, version) instead of account) and the same off-chain ceremony commits via RotateCommittee with scope = SecretSpecific(...).
ExpireLivenessChallenge (opcode 82)
node/execution/src/cbss.rs::handle_cbss_expire_liveness_challenge):
- Charge
CBSS_GAS_EXPIRE_LIVENESS_CHALLENGE = 5_000. - Load the challenge; require
status == PendingANDcurrent_block > challenge.deadline, elseInvalidData. - Transition
status → Unansweredand clear the response fields. proxy.health.unanswered_challenges += 1; apply the capped liveness-attrition penalty (COW-1058:CBSS_LIVENESS_HEALTH_PENALTY_BPS = 100per unanswered, bounded byCBSS_MAX_HEALTH_LOSS_PER_EPOCH_BPS = 1000) tohealth.score_bps.- If the penalty crosses below
MIN_HEALTH_FLOOR_BPS(or sustained sub-threshold ≥GOVERNANCE_REVIEW_BLOCKS), write aProxyGovernanceReviewFlagand emitcbss.proxy.governance_review_required. - Refund the challenge bond to
runner_request.body.runner_id. - Delete the liveness dedup-index entry; emit
cbss.liveness.expired { challenge_id }.
RequestReshare (opcode 83)
handle_cbss_request_reshare):
- Charge
CBSS_GAS_REQUEST_RESHARE = 30_000;ensure_cbss_actor_exists. - Load the release-key material for
scope;InvalidDataif absent. - Require
tx.from == material.owner, elseUnauthorized. - Push a
reshare_requestedevent into the CBSS actor event log (whichcbssdpolls — COW-2338: this is the third reshare trigger alongside the churn and deregister paths). No bond is locked and the committee state is not mutated on-chain; the event signalscbssdto orchestrate the off-chain reshare, which lands via a subsequentRotateCommittee.
ForcedDeregisterCbssProxy (opcode 84)
tx.from == GOVERNANCE_SYSTEM_ACTOR = 0x09). This is the CIP-12 Tier-1 remediation path for persistent faults or disclosed leaks (§4.3, §5.2). Effects (handle_cbss_forced_deregister_proxy):
- Charge
CBSS_GAS_DEREGISTER_PROXY = 8_000. - Require
tx.from == GOVERNANCE_SYSTEM_ACTOR, elseUnauthorized. - Load the proxy (
InvalidDataif absent); setproxy.suspended = trueandproxy.unbond_at = Some(current_block)— immediate unbond, no delay (contrast the voluntaryDeregisterCbssProxypath, which still respects the cooldown). If the proxy sits on any live committee,RotateCommitteeis enqueued for each affected release key (as in voluntary deregister). - Emit
cbss.proxy.forced_deregistered { proxy_id }.
RegisterTlockRelease (opcode 152, provisional)
Registers a time-lock release request (§3.4.6) so the committee knows what identity to sign and when. Permissionless (any account), fee-bearing, rate-limited per block.TlockRequest { scope, tag, target_height, registered_at } keyed by (scope, tag, target_height); a duplicate key is rejected. The record (and any partials posted against it, below) is GC’d at target_height + TLOCK_RETENTION (§4). MIN_TLOCK_LEAD_BLOCKS ensures the committee has time to observe the request before the reveal height.
SubmitTlockRelease (opcode 153, provisional)
A committee proxy posts its partial signature on a registered tlock identity, only once chain head ≥target_height. Permissionless to read the result; only committee members may submit.
TlockRequest exists for (scope, tag, target_height); (b) head >= target_height (reject TlockNotYetReleasable otherwise); (c) proxy_id is a current member of the referenced release key’s committee; (d) the BLS partial check e(partial, G2_gen) == e(I_tlock, S_proxy) with identity/point-at-infinity rejection, where I_tlock = hash_to_G1(canonical(tag) || u64_le(target_height), "cbss/tlock/v1"); (e) (scope, tag, target_height, proxy_id) not already submitted (per-proxy dedup). Accumulated partials are public; once ≥ t are posted, any reader Lagrange-combines them into σ = MSK · I_tlock (§3.4.6) — no further authorization. No per-proxy receipt/audit record is written (unlike SubmitReleaseReceipt): time-lock release is public, so there is no access to audit.
3.6 Threshold ceremonies
3.6.1 DKG (initial keypair establishment)
Triggered byRequestAccountDkg (account-scoped) or by SetSecret with committee_override (secret-scoped). Off-chain protocol:
The protocol is the standard threshold BLS DKG (FROST-derived for BLS sigs) as exposed by the chosen vetted library. The devnet implementation uses the pinned Commonware BLS DKG state machine. Sketch:
- Round 1. Each proxy
isamples a random degree-t-1polynomialf_i(x)over the BLS12-381 scalar field and broadcasts Feldman/Pedersen commitments to its coefficients (peer-to-peer over the committee mesh; no on-chain anchor). - Round 2. Each proxy
isendsf_i(j)to each peerj, encrypted underj’s registry pubkey. Recipients verify the share against the broadcast commitments using a single G2 multi-scalar multiplication. - Aggregation. Each proxy computes its share
s_i = Σ_j f_j(i)(a scalar), summing only the qualified dealers when timeout/complaint handling excluded a dealer. The groupMPK = Σ_j C_{j,0}∈ G2 (sum of each qualified polynomial’s constant-term commitment in G2). Each proxy’s published VSS commitmentS_i = s_i · G2is computable from the aggregated polynomial commitments at indexi. - On-chain commit. Once each proxy has its share
s_iand the groupMPK, the committee assemblesnew_committee_sigs— t-of-n signatures over the canonical DKG-commit payload (see RotateCommittee). One proxy postsRotateCommittee(scope, new_committee_epoch = 1, new_mpk, vss_commitments = [S_1..S_n], new_committee_sigs). The chain verifies: (a) eachS_iis a valid G2 point, (b)new_committee_sigs.len() >= thresholdand all sigs are from members ofnew_committeeover the canonical payload, (c) on a reshare,new_mpk == prior_release_key.mpk. The chain does NOT verify Shamir consistency on chain — that’s the committee’s responsibility off-chain via VSS during DKG, attested to by the threshold signatures. (A naive “sum(S_i) == MPK” check would be mathematically wrong since S_i are polynomial evaluations, not coefficient commitments.)
DkgSabotage (§4.3) by any party submitting the equivocation evidence.
Implementation note: cbss-crypto exposes qualified-set finalization so an offline or excluded dealer no longer forces unanimous abort. This does not weaken the ship requirement that the final DKG be vetted and bias-free; the qualified-set hook is ceremony liveness plumbing, not a replacement for the audited DKG protocol.
3.6.2 Reshare (proactive secret sharing)
Triggered by:- Scheduled rotation: every
RESHARE_INTERVAL_BLOCKS(default ~6 months) for bothAccountReleaseKeyandSecretReleaseKeyrecords. - Committee churn: when the count of healthy committee members drops to
t + RESHARE_SAFETY_MARGIN. - Owner request: the owner of the account (or of the secret in the override case) pays for an immediate reshare.
MPK. Output is a fresh share polynomial of the same secret, distributed to a possibly-different committee of n proxies. MPK is preserved; committee_epoch increments. Existing SecretVersions retain their wrap_epoch — reshare does not invalidate stored ciphertext or in-flight release requests; PSS guarantees the new shares produce valid partials for any historical identity under the same MPK. The prior epoch’s release-key material is retained for a grace window, so CIP-9 volume wraps keep serving across a reshare for that window; a volume’s epoch-pinned material hash means it must re-wrap to a current epoch before the window closes (contrast the immediate re-wrap forced by a re-key, §9.3.1).
3.6.3 Threshold partial signature (release)
The runtime release operation, per the threshold-IBE construction in §3.4.3. Inputs at the proxy:- The runner’s release request (signed; carries
recipient,job_id,request_block,serve_epoch, and request-binding fields).serve_epochpins which share-polynomial epoch the runner expects partials from; proxies reject withEpochMismatchif their current release-keycommittee_epochdiffers. The IBE identity still comes from the SecretVersion’s immutablewrap_epoch, not fromserve_epoch. - The proxy’s local Shamir share
s_i ∈ scalar field of BLS12-381of the implicitMSK.
- Validates the request (§3.7), looking up the relevant
AccountReleaseKeyorSecretReleaseKeyper the request’srecipientfield and checkingbody.serve_epoch == release_key.committee_epochat current head. - Reads
wrap_epochfrom the SecretVersion on chain andmpk_g2 = compress(MPK)from the release key looked up in step 1. Computes the identity using the canonical encoding:I = hash_to_G1( u64_be(chain_id) || canonical(SecretId) || u64_le(version) || u64_le(wrap_epoch) || mpk_g2, domain = "cbss/ibe/v1" ). - Computes its partial BLS signature on the identity:
σ_i = s_i · I(a point in G1). - Returns the canonical
PartialSignResponsewire shape:The proxy does NOT include its own VSS commitment in the response — the chain looks upS_ifrom the release key’svss_commitments(current or retained-prior epoch) keyed byproxy_id+committee_epoch_at_serve. This prevents a malicious proxy from substituting a different commitment.
e(σ_i, G2_gen) == e(I, S_i) against the on-chain VSS commitment S_i ∈ G2.
The runner rejects identity/point-at-infinity σ_i partials before combining. It combines t partials via Lagrange interpolation in G1 to obtain the threshold signature σ = MSK · I ∈ G1, reads wrapped_dek.ephemeral_u (decoding it to U), reconstructs aad = u64_be(chain_id) || canonical(SecretId) || u64_le(version) || u64_le(wrap_epoch) || mpk_g2 || ephemeral_u, then derives the IBE key K = HKDF(serialize(e(σ, U)), "cbss/ibe/v1" || aad) and AES-GCM-decrypts wrapped_dek with K.
3.7 Proxy-side validation of release requests
Release authorization (D11). The node exposes two coexisting routes with distinct contracts; they are not unified:cbssd’s serve-time check first and, if it allows with a valid context, the finalized-authority check next, including for legacy jobs. Both must allow before a share is used. The daemon matches chain, job, actor, runner, secret, version, derived purpose and finalized height across the responses/request; mismatches fail closed. The serve-time route accepts verifier-supplied identity, not a caller-selected purpose or a substitute for authenticating the signed V4 request. Exact retries can return an already-recorded response after local deletion/readiness checks; they do not use the share again.
When creating a new partial, each proxy applies the following validation against chain state at the current head. An exact retry may instead return the already-recorded response after the local deletion/readiness checks described above; it does not repeat the new-partial checklist or use the share again.
StaleRequestBlock, SecretAccessDenied, SecretPending, SecretCommitteeUnavailable, etc.); the runner retries with corrected inputs.
3.8 Runner pull flow
3.9 Runtime sandbox: in-memory secret bundle (default), env vars only for spawned subprocesses
Default path: in-memory template substitution, no process env vars. Process-wide environment is leaky — other threads, libraries, signal handlers,/proc/$pid/environ reads, and crash dumps can all observe it; serialization mutexes only help cooperating code. The default release path therefore:
- Holds plaintext in a
Zeroizing<Vec<u8>>SecretBundlekeyed by KeyName. - Performs
${KEY}substitution at request-construction time insiderunner-http/runner-mcp(against headers, URL, body, JSON tool args) — the substitution layer reads the bundle directly without touching the process environment. - Drops the bundle (zeroizes) at the end of the runner-op.
- Never sets
os.environ[KEY]for HTTP / MCP / LLM tool calls.
- Build an explicit
env: HashMap<String, Zeroizing<String>>for the child. - Pass via
Command::env_clear().envs(env)— the child inherits ONLY the named entries, not the parent’s environment. - The parent’s process environment is never mutated.
- Drop the
Commandbuilder immediately after spawn and zeroize the source env map. - Residual exposure: Rust’s
std::process::Commandcopies env values into its own platform-specific storage before spawning, and the standard library does not guarantee that copy is zeroized. Treat subprocess env delivery as an explicit compatibility exception, not the default secret path.
- The actor’s PVM Python code observes only
${KEY}placeholders. The PVM does not have aSecretBundlereference; substitution happens at the runner layer, after the syscall boundary. - Direct
os.environ[KEY]reads from PVM Python are blocked by the sandbox for any key declared in the actor’ssecrets.readentitlement (deny-list installed at sandbox init). - Logging adapters scrub strings matching active bundle values from spans and trace events on a best-effort basis. The primary defense is preventing PVM Python from ever holding the value.
4. Parameters
All parameters governance-tunable via CIP-12 Tier 0 proposals.4.1 Gas
Cycle costs below do not determine an access limit. Access rows, including deletion and evidence-history terms, are maintained in node’sdocs/access/CLIENT-LIMITS.md. The daemon estimates RotateCommittee against committed state before signing each attempt, including retained history and any account force-rekey volume page; it uses a bounded history-aware fallback if estimation is unavailable or unusable. An args-only formula is insufficient.
4.2 Stake / committee
Block-time basis (normative): this chain runs at 1 second per block (BLOCKS_PER_YEAR = 31_536_000,1 day = 86_400 blocks,node/types/src/constants.rs). Every duration-in-blocks below is computed against 1 s/block. ⚠️ Deployed-value deltas (2026-07): a few defaults below are governance-tunable and the deployed value differs; the deployed value is authoritative until governance sets otherwise.PROXY_SOAK_PERIOD— deployed 100 (execution/src/cbss.rs, a devnet/launch-short soak), vs the ~30-day mainnet target.RESHARE_INTERVAL_BLOCKS— deployed 1_296_000 (~15 days, a deliberate demo-scale cadence), vs the ~6-month production default.MIN_PROXY_STAKE— deployed 1_000 CBY (CBSS_MIN_PROXY_STAKE), not 10,000; open decision cowboy#238.GOVERNANCE_REVIEW_BLOCKS— corrected to 2_592_000 in code (see cowboy#243). These economic/launch values are governance decisions, not spec-edited here.
4.3 Slashing schedule
Provable on-chain only.PlaintextLeak and liveness non-receipt are NOT auto-slash:
- PlaintextLeak: evidence requires publishing the secret. Handled via off-chain disclosure → CIP-12 Tier 1
ForcedDeregisterCbssProxy. - Liveness non-receipt: not provable on chain (packet delivery is not chain-witnessable). Handled via the §4.4 health score → governance review.
ForcedDeregisterCbssProxy(proxy_id) and routes the entire stake to a remediation pool. The remediation-pool payout itself is governance-mediated (lean: capped at MAX_LEAK_REIMBURSEMENT = 100,000 CBY per incident).
Liveness handling: see §4.4 health score. No automatic stake loss on liveness; persistent degradation drives governance review.
4.4 Health score
Each proxy carries a health score in[0.0, 1.0], decayed and updated based on observable behavior. Used as a multiplier on VRF selection weight; persistent low scores escalate to governance review.
stake × max(MIN_HEALTH_FLOOR, health_score), where MIN_HEALTH_FLOOR (default 0.05) prevents complete exclusion that would block reshare unanimity.
Persistent extreme degradation (health < 0.1 for ≥ GOVERNANCE_REVIEW_BLOCKS ≈ 30 days) flags the proxy for an automatic CIP-12 Tier 1 deregistration proposal. Governance vote follows.
4.5 Quotas / limits
Carrying forward from the design notes §5.4 with no changes:5. SDK surface (CIP-6 extension)
5.1 New entitlement
{"account", "key"} is the canonical wire shape.
secrets.verify (§5.5) grants verify-only use of a key as an inbound-MAC key. Same keys shape and subset-upgrade rule. A key listed under secrets.verify is releasable for a verify= MAC check but is not readable via secrets=[...] (no ${VAR} substitution) unless also listed under secrets.read — this is what stops an actor from exfiltrating a signing secret it can only verify with. A key MAY hold both grants; within a single job the §5.5 overlap rule still forbids the same key serving both roles.
5.2 Runner-op kwarg
runner.llm accepts secrets=[...] only when the LLM op orchestrates server-side tool calls that need them (e.g., MCP tool plumb-through). Plain text-completion calls reject secrets=[...] with SecretsNotApplicableHere.
5.3 Errors
Canonical error names — implementations MUST use these to avoid divergent vocabularies. SDK / runner / proxy / chain handlers all map their failure modes to this set.5.4 CLI
5.5 Inbound-verification precondition (verify=)
Some runner-ops receive an inbound, externally-signed request (e.g., a Slack slash-command callback) and need to check the caller’s signature before proceeding — without pulling the underlying secret’s plaintext into the actor’s own code, only into the runner sandbox that already holds it for the op. CIP-24 adds an optional inbound-verification precondition to a job, exposed to callers as a verify= kwarg alongside secrets= (§5.2):
verify= normalizes to an optional JobPrecondition on the job spec:
VerifyAlgo carries provider-neutral crypto primitives only — never a provider name. This is the two-layer split: the provider-specific parts (the message preimage construction and the prefix choice) live in the actor / the connector’s integration definition at the control plane; the wire and consensus carry only neutral crypto. The single scheme today is Hmac:
[prefix ‖] encoding(HMAC-<hash>(secret, message)) and compares it constant-time to signature. prefix is a small scheme-version tag (≤ 16 bytes), None for a bare encoded MAC. Provider profiles are just field values:
MacHash/SigEncoding/VerifyAlgo tag (fail-closed, same discipline as HttpMethod) and an over-cap prefix. A genuinely new crypto scheme (e.g. ed25519 / RSA webhook signatures) — not a new provider — would add a new VerifyAlgo variant (tag 1+) via a CIP-24 amendment.
Admission checks. Both of the following are pure (no chain/registry state) and MUST be enforced in the same admission gate as the job spec’s other structural checks, before any secret lookup or release is attempted:
- Message length cap.
len(precondition.message)MUST NOT exceed 64 KiB (65536 bytes). Averify=request whosemessageexceeds the cap is rejected at admission, before verification is attempted. - Secret-ref-overlap rule (CIP-24 #37).
precondition.secret_refMUST NOT overlap — i.e., MUST NOT be the same{account, key_hash}pair — with any entry in the job’ssecret_refs(thesecrets=read set, §5.1/§5.2). A single job may verify a signature keyed by a given secret, or read that secret’s plaintext into the sandbox, but not both for the same{account, key_hash}in the same job.
verify= folds verification into the front of the runner op the actor was already going to dispatch.
Why a distinct secrets.verify entitlement (not secrets.read). The signing secret is not an outbound credential. If it were grantable via secrets.read, an actor could list it in secrets=[...] and exfiltrate it by reflecting ${VAR} into a request header or body. secrets.verify grants only the right to use a key as a verify= MAC key (§5.1): the runner refuses to place a verify-scoped key into the substitution bundle, and the release authorizer denies any reference whose purpose does not match the key’s grant (§3.1, SecretAccessDenied). A key MAY hold both grants; the overlap rule above still forbids both roles for the same key within one job.
Fail-closed ordering (normative). Within a runner op carrying verify=, the runner MUST, in order: (1) release verify.secret_ref under the secrets.verify purpose; (2) compute the MAC over message and compare it constant-time to signature; (3) on mismatch, abort with JobPreconditionFailed and invoke no executor — no HTTP request, no agent loop, no MCP call, no outbound side effect; (4) only on match, release the secrets=[...] read set, substitute, and execute. The runner MAY prefetch read-set secret metadata and plan committee fanout before the MAC check, but MUST NOT release or substitute any read-set plaintext until the MAC passes — a forged webhook never triggers a read-set release. The verify plaintext is confined to a verify-only ephemeral: it MUST be zeroized before the executor runs, and it never enters the ${VAR} SecretBundle (§3.9). JobPreconditionFailed is terminal: the assignment MUST NOT be retried or reassigned on MAC failure.
Message canonicalization. message is opaque bytes on the wire (base64 in the SDK JSON surface; the runner decodes to exact bytes and never parses or re-serializes them). The runner is preimage-agnostic — it MACs the bytes it is handed — so the actor MUST construct message as the provider’s exact canonical preimage over the verbatim received body. For the Slack profile (Hmac{Sha256, Hex, "v0="}), the actor constructs message as exactly b"v0:" + ascii(timestamp) + b":" + raw_body and the runner accepts iff signature == "v0=" + lowerhex(HMAC_SHA256(secret, message)). For the Twilio profile (Hmac{Sha1, Base64, none}), message is the URL followed by each POST param name+value in sorted order, and the runner accepts iff signature == base64(HMAC_SHA1(secret, message)). Any JSON re-encode, unicode round-trip, or whitespace normalization changes the bytes and MUST fail; a conformance test SHOULD sign a body with duplicate keys, trailing whitespace, and non-ASCII to ensure a reparsing implementation cannot pass by accident. New HMAC-based providers (GitHub sha256=, Stripe, …) are new Hmac field values chosen by the actor, never new protocol variants.
Threat model / DoS bound (normative, honest). The precondition authenticates the payload, not the dispatch: the actor still chooses to dispatch on an unverified inbound request, so a forged webhook costs one chain dispatch plus at most one verify-release attempt per assigned runner before the MAC fails and the op aborts terminally — and nothing more: no read-set release, no executor work, no outbound side effect, no plaintext leak. Because adaptive committee sizing MAY assign M runners to an op, verify-precondition ops SHOULD execute single-runner (redundancy buys nothing for a fail-closed gate), and JobPreconditionFailed being terminal prevents reassignment from multiplying attempts. Actors SHOULD further bound the residual with on-chain, secret-free prefilters applied before dispatch: a message-size cap, a timestamp-freshness window, exact-replay suppression keyed on (ts, signature, body_hash) — the only fields fixed before the MAC; it drops byte-identical resends but is not forgery prevention — and, the real ceiling, actor-level per-interval rate and spend caps. Per-channel / per-user limits are best-effort only: the attacker controls the body before the MAC passes, so sender identity cannot be authenticated pre-verification. The guarantee verify= provides is that no forged request ever produces an authenticated side effect or a read-set release; the cost it does not eliminate is the bounded dispatch + verify-release attempt(s), which the spend cap ceilings.
6. Implementation notes
6.1 Crate layout
6.2 Runner-side SecretsClient trait
PartialSign RPCs over QUIC+bincode → BLS partial-signature pairing-verify → Lagrange combine → IBE decrypt of wrapped_dek (K = HKDF(serialize(e(σ, U)), "cbss/ibe/v1" || aad)) → fetch CBFS object and AES-GCM-decrypt with DEK → return Zeroizing<Vec<u8>> per key. The bundle drops at end of runner-op. No env-var injection on the default path — runner-http / runner-mcp perform ${KEY} substitution from the bundle directly at request-construction time (see §3.9). Only spawned subprocesses receive env vars, via an explicit env map (Command::env_clear().envs(...)).
6.3 No persistent owner-side DEK cache
The per-account anchoring + IBE construction means owners do not need a local DEK cache. ACL changes (UpdateSecretPolicy) are pure metadata and require no re-wrapping. The cowboy secrets set CLI holds a DEK in process memory only for the duration of one set/finalize operation, and zeroizes on exit. There is no on-disk cache to manage, prune, or seal.
6.4 RPC additions
Inrpc/:
7. Implementation tracking
Implementation deliverables and test scenarios are tracked incbssd-implementation-plan.md: chain handlers, cryptography, DKG/reshare, release paths, SDK, CLI, and documentation. External crypto review is required before launch.
The CIP-9 volume-DEK integration (§9.3) includes the cbssd volume seal service, release-key material-hash binding, re-wrap-after-re-key flow, share finality, and the node’s hold-until-key-delivery gate. It uses the WrappedDek format version 2 specified here. Governance enactment provides the system-scope DKG bootstrap (§3.5).
Implementation review uses cbss-coverage-manifest.tsv as the B12
coverage index. That manifest maps the release-path, SDK/CLI/liveness, e2e,
stress, and adversarial scenarios from the implementation plan to concrete
source/test anchors, and cbss-review.sh validates those anchors on each run.
Remaining implementation and validation work is tracked outside this CIP text: full Intel
DCAP/TDX and AMD SEV-SNP/VLEK vendor-collateral validation, restoration of the
full spawned §5.9 DKG proof matrix in the current cargo harness, high-rate
stress against a spawned validator, a fake-cbssd Byzantine binary over QUIC,
and broader real-validator negative/recovery scenario coverage. External crypto
review remains a launch requirement.
The reviewer packet is cowboy/docs/security/cbss-crypto-external-review-packet.md.
8. Security considerations
8.1 The t-collusion question
For the default(n, t) = (5, 4), an attacker needs to compromise 4 of 5 specific operators simultaneously. With per-account anchoring (§10.2), a successful t-collusion exposes all of the account’s default-path secrets for the duration of the compromise window, retroactively decrypting any wrapped DEKs the colluding proxies could observe at release time. Hardening levers:
- Operator diversity (governance): jurisdictional, software-version, hardware-source, organizational. Governance MAY refuse to admit registrations that concentrate the proxy set.
- Governance teeth on disclosed leaks: PlaintextLeak is NOT auto-slashable on chain (§4.3) because objective on-chain evidence would publish the secret. A disclosed leak triggers a CIP-12 Tier 1 governance proposal that, if passed, calls
ForcedDeregisterCbssProxy(proxy_id)and routes the offending proxy’s stake to a remediation pool (default capMAX_LEAK_REIMBURSEMENT). WithMIN_PROXY_STAKE = 10,000 CBYand defaultt = 4, t-collusion still risks at leastt × MIN_PROXY_STAKEplus permanent ban — but the slash is governance-mediated, not protocol-automatic. - VRF committee selection: attacker cannot pre-position; committee membership is determined per-account at DKG time.
- Proactive reshare (insider rotation, NOT cryptographic invalidation): every
RESHARE_INTERVAL_BLOCKS, the share polynomial rotates. PSS preservesMSK, so a retained old share can still partial-sign any historical-wrap_epochidentity off-chain — there is no cryptographic invalidation of old share material. What reshare DOES do: (a) replaces the share at-rest in cbssd’s storage (an honest proxy zeroizes perDeleteSecretVersion/reshare protocol), (b) rotates committee membership so previously-compromised proxies must be re-compromised under the new committee_epoch to keep contributing to t-of-n, (c) makes the prior committee no longer accepted by the chain for receipts pastRESHARE_GRACE_BLOCKS. The honest-share-zeroization assumption is operational, not cryptographic. An exfiltrated share retains its contribution to offline threshold operations over historical identities.DeleteSecretremoves the on-chain version rows and pointer and revokes subsequent chain-authorized release; it does not cryptographically invalidate copied shares, wrapped DEKs or ciphertext. - Per-secret committee override: for secrets where the per-account blast radius is unacceptable, the
committee_overridepath triggers a one-off DKG with its own committee, isolating that secret’s compromise surface from the rest of the account. Owners SHOULD use override forSTRIPE_LIVE_KEY-class secrets and let routine credentials ride the account default.
8.2 Owner key compromise
If the owner’s account key is compromised, the attacker can issueUpdateSecretPolicy to add an attacker-controlled actor to the ACL, then fetch any of that owner’s secrets. CBSS does not defend against this and does not attempt to: account-key compromise is the limit of the model and is recovered from out-of-band (account-recovery flows are out of scope for this CIP).
8.3 Compromised runner
A compromised runner can exfiltrate the plaintext for the secrets it is currently authorized to fetch. It cannot:- Fetch secrets retroactively (release is anti-replayed per
(job_id, secret_id, runner)). - Impersonate other runners (signatures over runner registry key).
- Fetch secrets for jobs it isn’t currently assigned to (proxy validates dispatcher’s on-chain assignment).
8.4 Validator / dispatcher / chain-observer privacy
8.5 Anti-replay and freshness
- Per release:
(release_id, proxy_id)is the dedup key.release_idis defined canonically in §3.5 next toReleaseRequestBody; it is stable acrossrequest_blockre-pinning and includesrelease_nonce, so legitimate second pulls within a job get a distinct release_id. Proxies maintain a 24-hour bloom keyed on(release_id, proxy_id); chainrelease_receipt:{release_id} → { quorum_epoch, entries: Map<ProxyId, ReleaseReceipt> }provides eventual-finality dedup and single-epoch quorum enforcement.request_hash(which includesrequest_block) is used only for sig verification on the specific request, not for dedup. - Pinned-version pulls fail closed if the version was deleted (
SecretVersionDeleted). wrap_epochis read from the SecretVersion on chain (immutable per version); reshare does NOT invalidate in-flight requests since PSS preserves MSK and the proxy’s current share validly partial-signs any historical identity.
8.6 DKG correctness and liveness
- Correctness: Pedersen VSS commitments allow any party to verify share validity. A proxy submitting a malformed share is detectable and slashable for
DkgSabotage. - Liveness: if the ceremony fails to finalize within
DKG_FINALIZE_BLOCKS, the requesting tx’s DKG bond is partially slashed and refunded; the owner may retry. Lean: VRF-selectn + 2proxies and requirento complete (over-sample for ceremony liveness).
8.7 Future-shock: cryptographic break
A break of BLS12-381 (pairing-based co-BDH, the assumption IBE wrap confidentiality rests on per §3.4.4, or co-CDH for BLS signature unforgeability), AES-256-GCM, or secp256k1 ECDLP is a full-platform compromise — none unique to CBSS, all shared with the broader Ethereum/Filecoin/Cowboy ecosystems. As in §8.1,DeleteSecret revokes chain-authorized release; deletion does not cryptographically invalidate copied ciphertext or key material. Curve agility (e.g., upgrading the IBE construction to BLS12-461 or a post-quantum threshold scheme) is a future CIP, out of scope here.
8.8 Comparison vs TEE-based release
tee_required); the two layer cleanly.
9. Interaction with other CIPs
9.1 CIP-2 (Off-chain compute)
CBSS state belongs to system actor0x0000…0004 (CIP-2 §Specification; storage keys and lifecycle in §3.2).
9.2 CIP-6 (SDK + entitlements)
secrets.read is registered in the entitlement registry. The subset-on-upgrade invariant (CIP-6 §12.1) applies: an actor cannot expand the keys it can request without redeploying.
9.3 CIP-9 (Runner storage)
CIP-24 is the key-delivery layer for CIP-9 private volumes: a volume’s data-encryption key (DEK) is a CBSS-released secret. The runner obtains the DEK through a committee seal; the Dispatcher is not on the key path. Volume DEK as a committee secret. At volume creation the DEK is IBE-wrapped to the owner account’s CBSS master public key (MPK), like anySecretVersion (§3.4). The wrap is stored on the volume’s StorageCommitment as wrapped_dek, bound to the account’s cbss_committee_epoch. StorageCommitment.wrapped_dek uses the same WrappedDek format version 2: it carries ephemeral_u, uses K = HKDF(serialize(e(I, MPK)^r), "cbss/ibe/v1" || aad) owner-side, and validates/decrypts with K = HKDF(serialize(e(σ, U)), "cbss/ibe/v1" || aad) runner-side.
The volume path keeps CIP-9’s own identity and base AAD — not the 172-byte secret base_aad of §3.4.3. The IBE identity is I_vol = hash_to_G1( volume_dek_identity, domain = "cbss/ibe/cip9-volume-dek/v1" ), and the base AAD is base_aad = cip9_hash( "cowboy.cip-9.volume-dek-aad.v1", volume_dek_identity ) — a 32-byte value, where volume_dek_identity is CIP-9’s 32-byte volume-DEK identity hash (“Release at mount” below). The full AEAD AAD is base_aad || compress(U) (32 + 96 = 128 bytes), validated by full equality exactly as in §3.1 (len(aad) != 32 + 96 is malformed and MUST be rejected with AadMismatch). The HKDF salt and info prefix remain "cbss/ibe/v1" (§3.4.3) — only the hash-to-curve domain and base AAD differ on the volume path.
Release at mount. When a storage-attached job is dispatched, the node builds a SealRequest for the volume (carrying owner, volume_id, dek_version, committee_epoch, and the material hash below) rather than unwrapping anything itself. The committee serves threshold partials per §3.6.3 against the volume-DEK identity volume_dek_identity(chain_id, owner, volume_id, dek_version, committee_epoch, release_key_material_hash, committee_override_hash); the runner Lagrange-combines t partials to recover the DEK. Hold-until-key-delivery: the node holds the job — it is not exposed to a runner — until the release finalizes (idempotent on duplicate delivery), so a runner receives a storage job only once it can actually decrypt the volume.
Trading-Post-conditioned volumes (CIP-33). For a volume registered as a CIP-33 serving artifact, this release rule gains one additional condition: the SealRequest is honored only when the CIP-33 §2.5.1 issuance predicate (hire, term, and lifecycle state) also passes. The hire/artifact identities are not request fields — SealRequest is unchanged, byte for byte: cbssd recovers them deterministically from the authenticated JobSpec the request’s job_id resolves to (job → (hire_id, artifact) binding is consensus state written at dispatch, per CIP-33 §2.5.1), and evaluates the predicate against chain state at the current block. A request whose job carries no hire binding for a Trading-Post-registered volume is refused. Replay tests: a stale SealRequest re-presented after hire revocation or lease-epoch advance is refused by the same state read — the request asserts nothing the state does not confirm. Identity derivation, DST, AAD, material-hash binding, and rewrap semantics are unchanged.
9.3.1 Release-key material-hash binding (re-key safety)
A volume wrap is pinned to the exact committee key material it was created against, via a 32-byte release-key material hash:cip9_hash("cowboy.cip-9.release-key-material.v1", chain_id ‖ account ‖ committee_epoch ‖ threshold ‖ committee ‖ MPK ‖ VSS_commitments). The volume’s StorageCommitment records cbss_release_key_material_hash (the material the DEK is wrapped against) and cbss_rewrap_required.
At mount, a private attachment requires:
cbss_release_key_material_hashis set (a zero hash is rejected);cbss_rewrap_required == false;- the recorded hash equals the release-key material retained for the volume’s
committee_epoch(release_key_view.material_hash).
MPK and increments committee_epoch. A volume’s wrap is pinned to a specific epoch’s material hash — unlike a general SecretVersion, whose wrap_epoch-bound identity makes reshare fully transparent (§10.7) — so reshare is transparent to a volume only while the prior epoch’s material is retained, during the grace window: the volume keeps serving against its recorded epoch for that window, after which its wrap is stale and must be re-wrapped to a current epoch. An account re-key (recovery: the release-key material fundamentally changes) is the harder case — existing wraps can no longer be served, so affected volumes are flagged cbss_rewrap_required = true immediately and mounts are blocked until the owner re-wraps. Either path resolves the same way: re-wrapping the DEK to the current material updates cbss_release_key_material_hash and clears cbss_rewrap_required (the CBFS CLI ships the flow).
Share finality. Proxies finalize their shares before serving partials (bounded pending share promotions), so a release never combines partials against a half-promoted share across a ceremony boundary.
9.4 Protocol opcode allocation and system-actor registry
The canonical protocol codec assigns opcodes 68–84 to CBSS and 60–63 to the trusted-key TEE attestation instructions. See §3.3 for the instruction list and the Technical Whitepaper §9 for the system-actor registry.9.5 CIP-23 (TEE Execution)
CIP-23 attestation is optional per secret via thetee_required flag. When set, the runner MUST present a CIP-23 attestation valid for the active job_id; the chain validates TEE eligibility at receipt submission using the logical TEE Verifier records physically stored at 0x04 (§3.2), and proxy serving must fail closed unless the node authorizer path can confirm a live attestation. The current devnet TEE Verifier validates registered P-256/P-384 ECDSA attestation keys over canonical CBSS attestation bytes. It does not yet validate Intel DCAP/TDX or AMD SEV-SNP/VLEK vendor collateral; that remains TEE verification work. TEE here is defense-in-depth on top of threshold release, not the trust root.
9.6 CIP-34 (Intent Settlement)
CIP-34’s sealed-bid auctions consume the time-lock release mode (§3.4.6): a bid is IBE-encrypted to identityrequest_id with target_height = reveal_height, and the committee posts the height-gated release at reveal_height so the native reveal handler (and any observer) can decrypt all bids at once — “the key becomes available independent of any bidder.” This supplies the caller-chosen auction identity and automatic height-based release required by CIP-34 §Sealed-Bid. No new committee, key, or trust is introduced — sealed auctions ride the same staked CBSS committee. CIP-34 owns the liveness fallback (AUCTION_GRACE → cancel the auction, CancelledGraceExpired; no OPEN re-open, no escrow held) if the committee fails to release by reveal_height + AUCTION_GRACE.
Bootstrapping the 0x14 committee. The sealed-bid path presumes Account(0x14) already has a live CBSS committee / MPK, but 0x14 is keyless and cannot self-issue RequestAccountDkg. GovRequestSystemDkg (opcode 158, §3.5) closes this: governance (0x09) issues one GovRequestSystemDkg { scope: Account(0x14), force_rekey: false } to establish the committee via the ordinary DKG → RotateCommittee path (no bond, no new trust). This MUST happen before the first OpenAuction. Governance MUST NOT force_rekey the 0x14 committee while auctions are in flight (see §3.5 — a re-DKG installs a new MPK and strands in-flight bids); routine refresh uses RequestReshare (§10.7).
10. Rationale
10.1 Why threshold IBE on BLS12-381
See §3.4.5 for the full argument. Short version: vetted libraries exist for threshold BLS signatures and IBE on BLS12-381 (blstrs, Commonware BLS DKG, tlock); they do not exist for threshold ElGamal on secp256k1. Using vetted cryptographic implementations takes priority over reusing the account-signature curve.
The IBE construction does not use kfrags or proxy re-encryption; it uses BLS signature shares as IBE decryption keys. The recipient-targeting problem that kfrag-based PRE designs address is solved here by binding the IBE identity to per-version metadata that all parties can compute deterministically.
10.2 Why per-account by default, with per-secret override
The default is a per-account committee, with per-secret overrides for stronger isolation.- Per-secret committees scale
O(num_secrets × n)system-wide with a fresh DKG every time someone stores a credential. Bootstrap-prohibitive. - Per-actor committees isolate an actor’s secrets from the rest of an account’s secrets, but the isolation only materializes when ACLs are narrow. Once a secret’s ACL fans out across multiple actors, an attacker just attacks the weakest of those committees — per-actor is no better than per-account in the wide-ACL case. Per-actor also pays a DKG every time you deploy a new actor, and forces a chain re-wrap on every ACL edit (since each ACL actor needs its own wrapped DEK). Operationally heavy with limited security gain in realistic usage patterns.
- Per-account committees match the practical sharing patterns of production secret management (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault are all account/project-scoped). Bootstrap is one DKG amortized over the account’s lifetime. ACL edits are pure metadata. Storage is
O(1)wrapped DEK per version regardless of ACL size.
MPK. Mitigations:
- Tunable
(n, t)per account — high-value accounts can raisenandtthrough governance-supported account parameters once larger vetted DKG quorums are enabled, instead of using the default(5, 4). - Per-secret committee override — a single high-value secret (
STRIPE_LIVE_KEY) can opt into its own committee atSetSecrettime, isolating its blast radius from the rest of the account’s secrets. This is the escape hatch. - Proactive resharing — every
RESHARE_INTERVAL_BLOCKS, the share polynomial rotates. An attacker has a finite window. - Operator diversity governance — committee admission policy can require jurisdictional / vendor / org diversity.
10.3 Why a new operator role, not extending Relay Nodes
Different failure semantics, slashing math, and operator profile (§3.1). A node can register as both Relay and Proxy; the protocol roles stay distinct.10.4 Why in-memory bundle by default (and explicit subprocess env when required)
Limits blast radius. Plaintext exists only in the runner-op execution context, only as the substituted final-form request. Never enters PVM Python state; cannot be accidentally logged, persisted, or echoed in error messages.10.5 Why two-layer access control (manifest ∩ ACL)
Manifest is the actor’s advertised need (caught at deploy-time review). ACL is the owner’s consent (caught at the runtime gate). Both layers answer different threats: a malicious actor lying about its needs is caught at deploy time; a compromised owner key that grants too broadly is at least bounded by the manifest of each individual recipient actor.10.7 Why MPK is preserved on reshare
Owners encrypt to MPK. If reshare changed MPK, every existing wrapped DEK on chain would become un-decryptable, forcing owner re-encryption en masse. Preserving MPK across reshare (proactive secret sharing on the same MSK) means reshare is invisible to owners — only the share polynomial rotates.
Note: the per-version IBE identity is bound to the SecretVersion’s immutable wrap_epoch, not to the live committee_epoch. Reshare changes committee_epoch but does NOT change any existing version’s wrap_epoch. New SetSecret calls after a reshare bind to the then-current committee_epoch as their wrap_epoch. The threshold-sig math works for any historical identity because PSS preserves MSK across reshare.
10.8 Why per-proxy receipts, not a single aggregate RecordSecretRelease
A single aggregate audit/payment instruction submitted “by the runner or any participating proxy” would leave payment dependent on either runner cooperation (which the runner has no incentive to provide once it has the plaintext) or implicit proxy coordination (unspecified, and prone to gas-bidding wars or drop-on-the-floor).
SubmitReleaseReceipt (opcode 76) instead makes each proxy submit its own receipt independently. Each accepted receipt debits RELEASE_FEE_PER_RECEIPT from the actor’s account (subject to overdraft cap) and credits the submitting proxy. Payment is capped at quorum: only the first t receipts per release_id are paid; receipts t+1..n (which a runner may have triggered by over-fanning out) are rejected with QuorumAlreadyReached. Properties:
- Runner is removed from the payment path. Cannot grief proxies by withholding submission.
- No coordination required. Each proxy decides independently when to submit; the first t proxies to land on chain are paid; later submissions for the same release_id (whether from the n-t over-fanout or from the same proxy on a re-pin) are rejected. Proxies SHOULD watch
release_receipt:{release_id}.entriesand stop attempting onceentries.len() == t. - Auditable.
release_receipt:{release_id} → { quorum_epoch, entries: Map<ProxyId, ReleaseReceipt> }accumulates the full t-of-n proof of release on chain, each entry carrying the verbatim runner-signed request and the verifiedσ_i. TheSecretReleasedevent fires when the t-th entry lands, carrying the actualproxy_setof t contributors. - Cost: t txs per release instead of one. Acceptable for a system whose default release rate is O(1 per actor-job); for very-high-frequency cases, an aggregator pattern is possible later without breaking the on-chain interface.

