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. Today there is no protocol surface for storing them. 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: CIP-9 v1’s dispatcher-unwrap scheme put the dispatcher in the trust path for DEK release — never acceptable for third-party credentials, and since superseded for volume DEKs too (CIP-9 now releases them via CBSS committee seal, §9.3). 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.
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 the dispatch point that makes future format changes (KDF, curve, post-quantum constructions) deployable without a chain reset. The tag is wire framing only: it is NEVER part of the AEAD aad or the key derivation. v1 three-field envelopes are not valid for CIP-24 IBE wraps and there is no v1→v2 migration path: any state wrapped under v1 MUST be discarded (devnet reset) or re-wrapped under v2 before an implementation of this amendment serves it; pre-mainnet, no backwards compatibility is maintained. 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.
tee_required secrets, CBSS also reads TEE Verifier (0x05) state at tee_att:{job_id_32}:{runner_20}. The value is a TeeAttestationRecord { job_id, runner, tee_type, measurement_hash, attested_at_block, expires_at_block, revoked }; CBSS never accepts a proxy-supplied TEE assertion. The TEE Verifier owns the companion trust store tee_key:{tee_type}:{measurement_hash_32} → Vec<EncodedEcdsaPublicKey>: SGX/TDX entries are uncompressed P-256 SEC1 public keys, and SEV-SNP entries are uncompressed P-384 SEC1 public keys. Records are written only by a signed-attestation path that verifies the canonical cbss/tee-attestation/v1 payload over the job, runner, normalized TEE type, measurement, expiry, and quote bytes. This is the current devnet verifier boundary. Full Intel DCAP/TDX collateral validation and AMD SEV-SNP VLEK certificate-chain validation are deferred to the v1.1 / pre-mainnet TEE milestone.
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 amendment.
3.3 Master opcode allocation update
CIP-24 claims opcodes 68–84 from the master table maintained in whitepaper §9.2. The amended row block (to be merged in the same PR as this CIP):152/153 are provisional placeholders, not a fixed reservation. Like every system-instruction opcode, they are allocated next-free against the SystemInstruction enum and the opcode-uniqueness test at implementation time (there is no central normative opcode table to amend — see CIP-34 §Protocol Allocations). They are shown as 152/153 only to keep them clear of the currently-documented ranges and of CIP-34’s own (also unpinned) Intent* settlement opcodes; CIP-34 and this amendment are authored under the same effort, so the actual contiguous CIP-34 ↔ CIP-24 opcode ordering is reconciled at impl time. The opcode-uniqueness test is the binding gate.
TEE Verifier support instructions take opcodes 60–63 and are consumed by the 0x05 actor state that CIP-24 reads: 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 — superseding earlier draft claims in CIP-13 v2 §1 / CIP-23 v2 / CIP-10 v2 that proposed GcNonces / container ops at these opcodes. The pre-activation drafts of those CIPs need their own renumbering against the code-based master table; see CIP-13 §1 (revised 2026-05-26).
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 deferred to June 2026; this is a pre-mainnet governance gate, not a current implementation-completion blocker.
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-7 and CIP-9, which do have epoch-scoped re-wrap models, bind release_key_material_hash instead — see §9.3 and CIP-7. 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.
- 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 — which is why this base-AAD layout can only be introduced on a wiping re-genesis (there is no re-wrap path that reconstructs the value envelope without the plaintext). - 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). CreatesSecretVersion(version = latest + 1)underSecretId(sender, key)withpending = false. 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. (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 }.
If the owner never finalizes (e.g., abandons the secret), the version remains pending indefinitely and consumes a quota slot. A future garbage-collection mechanism MAY reclaim long-pending versions; out of scope here.
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)
SecretVersion from chain state, adds an entry to secret_version_tombstone:{account_hex}:{H(key)_hex}:{version_be8} (singular path; matches §3.2 storage layout). Tombstone carries {deleted_at_block, was_pending} so future lookups can distinguish “deleted” from “never existed”, never the ciphertext. If pending == false, also emits a CBFS drop instruction for the cbfs_pointer; if pending == true, no CBFS object exists, so the CBFS drop is skipped. Subsequent release attempts against this version fail closed with SecretVersionDeleted (the tombstone is what makes this distinguishable from SecretNotFound). Pending releases that referenced this version (already in flight at proxies) fail at the proxy validation step.
DeleteSecret (opcode 71)
DeleteSecretVersion. SecretMetadata removed. The account’s AccountReleaseKey is not removed (it may serve other secrets). Any per-secret SecretReleaseKey records owned by the deleted versions ARE cascaded into deletion: the chain emits a ShareZeroizationRequested { release_key_id } event per affected SecretReleaseKey. Committee members SHOULD respond by zeroizing their local shares and recording the action in their cbssd audit log; the chain does NOT enforce zeroization (no quorum signature or attestation is required by the protocol — false attestations would be evidence in a future leak case but cannot be objectively verified at deletion time). This is documented and accepted: deletion is a request to the operator network, not a cryptographic guarantee. Owners should treat zeroization as best-effort and not rely on it for forward secrecy.
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 VSS commitments (each S_i ∈ G2) and quorum signatures (when applicable), atomically updates the targeted AccountReleaseKey or SecretReleaseKey and increments committee_epoch. Reshare grace: before overwriting, the prior (committee_epoch_old, committee_old, vss_commitments_old, expires_at = current_block + RESHARE_GRACE_BLOCKS) is appended to prior_committee_epochs:{release_key_id} (a Vec, NOT a single slot — handles back-to-back reshares within one grace window). On append, any entries whose expires_at < current_block are GC’d first. If the resulting Vec would exceed MAX_PRIOR_COMMITTEE_EPOCHS (§4.2, default 4), the RotateCommittee instruction is rejected with ReshareGraceCapacity — the operator must wait for at least one prior epoch to expire, OR governance can override via a CIP-12 emergency proposal that prunes oldest entries. This bounds storage growth while preventing the one-slot-overwrite bug.
Receipts whose committee_epoch_at_serve matches any retained entry in prior_committee_epochs (and whose submission is before that entry’s expires_at) are accepted; outside any retained entry’s window, they’re rejected with StaleCommitteeEpoch. 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.
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.
Sender: the runner identified by runner_request.body.runner_id.
Effects: all validation at current head (no historical reads), matching the receipt model:
runner_request.runner_sigverifiesrequest_hash = keccak256(serialize(runner_request.body))against the on-chain runner registry pubkey forbody.runner_idat current head.- ACL/manifest/job-assignment for
(body.actor, body.runner_id, body.job_id, body.secret_id)valid at current head. SecretVersion[body.secret_id, body.version].pending == false— challenges against pending versions are rejected withSecretPending(a pending version is never releasable, so a missed-request claim against it is meaningless).body.request_block + MIN_CHALLENGE_DELAY_BLOCKS ≤ submission_block ≤ body.request_block + LIVENESS_CHALLENGE_MAX_AGE_BLOCKS. The lower bound (premature →ChallengePremature) gives the proxy time to serve + submit a receipt before being challenged. The upper bound (defaultMIN_CHALLENGE_DELAY_BLOCKS + LIVENESS_RESPONSE_BLOCKS, ~32 min) bounds how stale a challenge can be; older challenges fail withStaleChallenge. (challenged_atis set tosubmission_blockby the handler.)body.recipient == SecretVersion[body.secret_id, body.version].recipient(same recipient-consistency check as receipts; prevents committee-substitution challenges).proxy_idis a member of the referenced release key’s current committee OR any retained prior committee (i.e., any entry ofprior_committee_epochs:{release_key_id}whoseexpires_at > submission_block). The challenge does NOT carry acommittee_epoch_at_serve(unlike receipts); the chain accepts membership in any currently-recognized committee. Rationale: the challenge is alleging the proxy failed to respond at SOME point during its tenure; pinning a specific epoch would require the runner to know which committee_epoch the proxy was supposed to serve under, which the runner doesn’t necessarily know at challenge time.challenge_sigverifies the outer challenge envelope underbody.runner_id’s registry key.- Per-release liveness dedup. No prior
liveness_challenge_index:{release_id}:{proxy_id}entry exists. A(release_id, proxy_id)pair admits at most ONE on-chain liveness challenge regardless of whether the previous challenge is pending or terminal. Duplicate submissions are rejected withLivenessChallengeAlreadyOpen. This bounds health-score impact to one challenge per logical release per proxy and prevents repeated false-alarm bond transfers. - The chain checks whether
release_receipt:{release_id}.entries[proxy_id]already exists. An existing receipt is not a rejection: it proves the proxy already served and makes the challenge immediatelyResolved; the runner forfeits the bond to the challenged proxy.
LIVENESS_CHALLENGE_BOND from the runner’s account and writes liveness_challenge_index:{release_id}:{proxy_id} → challenge_id so subsequent duplicate challenges are rejected. 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, not aggregate. Each proxy that served a partial submits its own receipt independently. The receipt carries the runner’s original signed release request verbatim AND the proxy’s BLS partial signatureσ_i. The chain re-derives the request hash, verifies the runner’s signature, and verifies the BLS pairing equation e(σ_i, G2_gen) == e(I, S_i) — providing cryptographic proof that the proxy actually computed honest work, not just that the runner asked for it. A proxy that submits a receipt without computing σ_i cannot pass the pairing check.
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.
Sender: the proxy identified by proxy_id (its proxy_sig must verify against CbssProxy.pubkey).
Chain validation (current-head; the chain does not read historical state):
request_hashandrelease_idare recomputed fromrunner_request.body(NOT including the signature; see request_hash definition above).body.request_block ∈ [head − REQUEST_FRESHNESS_BLOCKS, submission_block]. Out of range →StaleRequestBlock.runner_request.runner_sigverifiesrequest_hashagainst the runner registry at current head.- Recipient consistency. Look up
SecretVersion[body.secret_id, body.version]. The receipt’sbody.recipientMUST equal the storedSecretVersion.recipient. Reject withRecipientMismatchotherwise. (Without this, a malicious runner could request a partial under a different release key’s MPK; the partial passes pairing under that other MPK but doesn’t decrypt this SecretVersion’swrapped_dek.) Also requireSecretVersion.pending == false. - ACL/manifest/job-assignment for
(actor, runner_id, job_id, secret)valid at current head. Trade-off: if the ACL was permitted atrequest_blockbut revoked within the freshness window before the proxy submits, the receipt is rejected — the proxy ate the work for that one request. This is the cost of avoiding a historical-read API. Bounded byREQUEST_FRESHNESS_BLOCKS(~6 min); proxies should submit promptly, and runners who try to weaponize this lose more from gas + grief than they gain. - Reshare-aware committee lookup. Resolve
(committee, vss_commitments)forcommittee_epoch_at_serve:- If
committee_epoch_at_serve == release_key.committee_epoch(current): use the live committee + commitments. - Else if
prior_committee_epochs:{release_key_id}contains an entry withepoch == committee_epoch_at_serveANDsubmission_block < entry.expires_at: use that entry’s committee + commitments. (Vec lookup; multiple prior epochs may be retained simultaneously per §3.5 RotateCommittee.) - Else: reject with
StaleCommitteeEpoch.proxy_idmust be a member of the resolved committee at that epoch.
- If
proxy_sigverifies over(request_hash, proxy_id, sigma_i, committee_epoch_at_serve, served_at_block)underproxy_id’s registered pubkey.- BLS partial-signature pairing check: chain reads
wrap_epochfrom the SecretVersion (chain has it; it’s immutable) andmpk_g2 = compress(MPK)from the release key resolved for this receipt (step 6). ComputesI = hash_to_G1( u64_be(chain_id) || canonical(body.secret_id) || u64_le(body.version) || u64_le(wrap_epoch) || mpk_g2, domain = "cbss/ibe/v1" )— identical encoding to the one the owner used at encryption time. (Thewrap_epochfield is historical whilempk_g2comes from the resolved — current — release-key record; this is sound because reshare preservesMPK, so the two agree for every epoch the chain will still serve; §3.4.3.) Looks up the proxy’sS_i ∈ G2from the resolved release key’svss_commitments(current epoch or grace-window prior). Rejects identity/point-at-infinitysigma_iorS_ibefore pairing. Checkse(sigma_i, G2_gen) == e(I, S_i). Fail →InvalidPartialReencrypt(chain emits a slashing-evidence event automatically; receipt rejected; no payment). (release_id, proxy_id)not previously recorded inrelease_receipt. Dedup is onrelease_id(which includesrelease_nonce), notrequest_hash— a runner who re-pins a freshrequest_blockfor the same nonce cannot get a second receipt billed by the same proxy. A runner who issues a freshrelease_noncefor a legitimate second pull within the same job CAN receive a second receipt — that’s the intended use of the nonce. 9a. Single-epoch quorum (runner-pinned).committee_epoch_at_serve == body.serve_epoch. The quorum_epoch is fixed by the runner up front in the signed body; the chain enforces every receipt’scommittee_epoch_at_servematches the runner’s declaredserve_epoch. Mismatch →MixedEpochReceipts. Lagrange combine works only over partials from the same Shamir polynomial; pinning the epoch in the signed request prevents a racing off-epoch proxy from locking the quorum_epoch to a value the runner did not intend. If a reshare lands between the runner’srequest_blockand PartialSign serve time, proxies whose currentcommittee_epochno longer matchesbody.serve_epochreject withEpochMismatch(see §3.7); the runner must re-issue with a freshrelease_nonceand updatedserve_epoch. 9b. Quorum cap.release_receipt:{release_id}.entries.len() < threshold(wherethresholdis read from the referenced release key forbody.serve_epoch, the same epoch stored asquorum_epochonce the first receipt is accepted). Once t receipts are recorded for arelease_id, additional receipts (from any of the other n-t proxies the runner may have fanned out to) are rejected withQuorumAlreadyReached. This caps payment at exactly t receipts per logical release, regardless of fanout. Proxies that respond after the t-th have already submitted have done valid work but are not paid — they MUST observe this and stop attempting submission once they see the t-th receipt on chain.served_at_block ≤ submission_block, andserved_at_block ≥ body.request_block.tee_attestedis chain-derived, not proxy-asserted. The chain looks up0x05TEE Verifier state attee_att:{body.job_id}:{body.runner_id}AT submission time. If the SecretVersion’spolicy.tee_required = true, the record must exist, be non-revoked, match the request’sjob_idandrunner_id, haveattested_at_block <= submission_block <= expires_at_block, and use a TEE type matching the runner registration. TEE Verifier records are produced bySubmitTeeAttestation, which verifies a registered ECDSA attestation key over the canonical attestation payload before writing: P-256 for SGX/TDX, P-384 for SEV-SNP. Otherwise the receipt is rejected withSecretRequiresTEE. The resulting boolean is what gets emitted in events; proxies do not declare it.
release_receipt:{release_id} does not yet exist, initializes it with quorum_epoch = body.serve_epoch and an empty entries map. Then inserts (proxy_id → ReleaseReceipt) into entries. Debits RELEASE_FEE_PER_RECEIPT from actor.account; credits proxy_id.
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 REQUIRES a CIP-12 ProposalPayloadKind (e.g. SystemDkgBootstrap { scope, force_rekey }) that ExecuteProposal maps to this instruction — this enactment mapping is a hard implementation dependency and MUST be added alongside the handler, or opcode 158 is unreachable (the handler exists but nothing can originate a 0x09 tx to reach it). See the open item in §9.6.
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.- No
RotateCommitteefor this scope has landed (i.e., the DKG didn’t complete in some other path).
(DkgPending.bond_amount - DKG_TIMEOUT_SLASH) to DkgPending.requester; routes DKG_TIMEOUT_SLASH to the slashing pool (a small fraction, default 5% of the bond, governance-tunable); also pays a small fixed reward to the calling account from the slashing pool to incentivize permissionless cleanup. Deletes dkg_pending:{scope_serialized}. If the scope was an override-path SecretSpecific(secret_id, version), the corresponding pending SecretVersion is erased without a tombstone — pending versions are not “real” versions until finalized, so they’re treated as never-having-existed when expired. The chain also rolls back SecretMetadata.latest_version if it was advanced by the abandoned SetSecret, freeing the version slot for retry. Emits DkgExpired { scope, requester, refunded, slashed }.
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
Each proxy enforces, on everyPartialSign RPC, that:
Proxies validate release requests against chain state at the current head (not historical). The table below is the canonical proxy-side validation checklist.
UpdateSecretPolicy removing an actor) are effective at chain finality. Proxies MAY cache chain reads with TTL ≤ PROXY_CACHE_TTL_BLOCKS. Net worst-case revocation latency is finality_blocks + PROXY_CACHE_TTL_BLOCKS × block_time. There is no “instant atomic” revocation claim — revocation is “effective at finality + cache TTL of any participating proxy.” A runner who tries to use a revoked credential between revocation and propagation gets a partial that’s authorized at the now-stale cached state; this is a documented and bounded window, NOT a vulnerability.
Failures are reported with structured error codes (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
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. (Earlier revisions derived some counts at ~12 s/block; thoseblocksvalues were 12× too small even where the wall-clock annotation was right — e.g.REQUEST_FRESHNESSandGOVERNANCE_REVIEW.) ⚠️ 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 is tracked incbssd-implementation-plan.md as a single delivery split across parallelizable workstreams (chain skeleton, cbss-crypto, cbssd DKG/reshare, release path, SDK/CLI/docs). The current implementation is intended for devnet merge and wider devnet testing, not a mainnet feature-flag flip. The external crypto review is deferred to June 2026 as a separate pre-mainnet governance gate. See the plan for per-workstream deliverables and test scenarios.
The CIP-9 volume-DEK integration (§9.3) is merged to devnet in its pre-amendment (v1-envelope) form: the cbssd volume seal service, the release-key material-hash binding and re-wrap-after-re-key flow (§9.3.1), share finality, and the node-side hold-until-key-delivery gate. The v2 WrappedDek envelope this amendment mandates is not yet merged anywhere — the reference PRs (see History) are in flight, and devnet stays on the superseded v1 wrap until they land and devnet state is reset.
The implementation review now 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.
Known v1.1 / pre-mainnet follow-ups are 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 deferred to June 2026; it is a separate mainnet flag-flip gate.
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 attacker who exfiltrates a share before reshare retains an offline capability to threshold-sign historical identities indefinitely; deletion of the underlying secret (DeleteSecret) is the only way to actually invalidate. - 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. The mitigation specific to secrets isDeleteSecret: dropping the CBFS object means a future cryptanalytic break finds nothing to decrypt. The chain history retains only the encrypted wrapped_dek, which without the CBFS ciphertext is useless. 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)
CIP-24 fills the system-actor reservation at0x0000…0004 declared in CIP-2 §Specification. CIP-2 §Specification table SHOULD be updated to footnote-reference CIP-24 in the same PR.
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. CIP-9’s earlier “CIP-TBD” forward-reference to a Secrets Manager is fulfilled here (the placeholder reads “CIP-24”). The CIP-9 v1 dispatcher-unwrap design is superseded — in the current CIP-9 spec the runner obtains the volume DEK only via a committee seal, and the Dispatcher is never 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.
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 Whitepaper §9.2 (Master opcode allocation)
CIP-24 claims opcodes 68–84 in the master allocation table. The table in whitepaper §9.2 MUST be amended in the same PR (§3.3 of this CIP shows the exact rows to insert).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 it against 0x05 (TEE Verifier) at receipt submission, 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 is a v1.1 / pre-mainnet milestone. 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 (v1) 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 is the dependency CIP-34 §Sealed-Bid names; before this amendment, CIP-24 exposed only the ACL-gated, fixed-identity secret-release path, which cannot serve a caller-chosen auction identity released automatically at a height. 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).
Open item — enactment bridge (blocks the SEALED path).GovRequestSystemDkg’s handler (node#909) is merged, but a0x09-origin tx can only be produced by CIP-12 proposal enactment via aProposalPayloadKind(§3.5, Invocation), and no such payload kind yet maps to opcode 158. Until that enactment mapping is added, opcode 158 is unreachable, so the0x14committee cannot be bootstrapped and no sealed auction can clear (open_auctionhard-requires the committee). This mapping (and the keyless-scope restriction in §3.5) are the remaining implementation items for the SEALED path.
History
2026-06-11: erratum-01 was folded directly into this CIP body instead of shipping as a separate erratum. The defect was that the earlier IBE wrap key used no per-message encryption ephemeral, makingK publicly recomputable from (MPK, aad) and causing the threshold release to add availability/policy gating but zero cryptographic confidentiality. The root-cause lesson: §3.4.5 correctly rejected a runner-binding ephemeral key, then over-generalized “no runner ephemeral” into “no ephemeral at all”, dropping the Boneh-Franklin encryption ephemeral r (owner-chosen and independent of runners). Boneh-Franklin IBE (CRYPTO 2001) and tlock (Cryptology ePrint 2023/189, presented at Real World Crypto 2023) require e(I, MPK)^r plus a public U = r · G2_gen; CIP-24 now does too. Reference implementation PRs (in flight, not yet merged, at the time of this amendment): cowboyinc/cbss#23, cowboyinc/node#689, cowboyinc/python-sdk#83. Companion docs PR aligning the secrets whitepaper and the CIP-7 spec to the v2 envelope: cowboyinc/cowboy#165.
2026-06-12: WrappedDek gained the leading 1-byte format-version tag (version == 2; §3.1) so future envelope-format changes dispatch on the tag instead of breaking the chain (COW-2259). Reference implementation PRs: cowboyinc/cbss#25, cowboyinc/node#706, cowboyinc/python-sdk#87.
2026-07-03: v1 amendment — governance-bootstrapped system committee (GovRequestSystemDkg, opcode 158). Adds the one DKG-bootstrap path for a keyless system-actor release-key scope: governance (0x09) issues GovRequestSystemDkg { scope, force_rekey } to establish a committee that cannot self-issue the owner-keyed RequestAccountDkg. The concrete consumer is CIP-34 sealed-bid auctions, whose reveal decrypts against Account(0x14)’s committee (§9.6) — before this, that committee’s MPK had no way to come into existence. Mirrors RequestAccountDkg (same DKG → RotateCommittee path) with governance authorization, an explicit target scope, and no DKG_BOND. Documents the force_rekey safety MUST (a re-DKG installs a fresh MPK and strands ciphertext bound to the old one — §3.5, §10.7 — so governance must not force_rekey 0x14 while auctions are in flight; routine refresh uses RequestReshare) and the cbss.dkg.requested DKG-request event both DKG paths emit. Authored under the 2026-06-29 cross-team mandate; this is the CIP-24 side of the CIP-34 v1 SEALED committee bootstrap. Reference implementation: codec cowboyinc/cowboy-protocol#19 (opcode 158 wire), node cowboyinc/node#909 (0x04 handler + pin bump).
2026-06-29: v1 amendment — time-lock release mode (§3.4.6). Re-introduces tlock’s native future-height identity binding as a second, domain-separated (cbss/tlock/v1), height-gated, permissionless release mode alongside the existing ACL-gated secret release — for CIP-34 sealed-bid auctions (§9.6), which need a key bound to a caller-chosen request_id that becomes available automatically at a reveal height, independent of any participant. Reuses the same committee / MPK / DKG / PSS (no new key or trust). Adds RegisterTlockRelease (opcode 152) and SubmitTlockRelease (153), the §4.5 TLOCK_* parameters, and the §9.6 CIP-34 interaction. Authored under the 2026-06-29 mandate to initiate cross-team work; this is the CIP-34 side of the CIP-34 v1 dependency. Reference implementation: TBD (cbss cbss-crypto tlock identity + node 0x04 handlers + the consumer wiring in CIP-34’s 0x14).
2026-08-03: Single canonical account-secret format at genesis (chain separation + release-key binding). The account-secret base AAD is finalized as the one canonical layout — base_aad = u64_be(chain_id) ‖ account(20) ‖ key_hash(32) ‖ u64_le(version) ‖ u64_le(wrap_epoch) ‖ mpk_g2(96) = 172 bytes, extended AEAD aad = base_aad ‖ compress(U) = 268 bytes, IBE identity I = hash_to_G1(base_aad, "cbss/ibe/v1"), HKDF salt/info "cbss/ibe/v1". Two release-integrity bindings are folded into that single layout: chain separation (COW-2660 — the leading chain_id, so a secret wrapped for one chain cannot be validated or unwrapped on another) and release-key binding (COW-2923 — the trailing raw mpk_g2, so a secret sealed under a substituted mpk' produces a different identity and AAD and the real committee’s σ can never open it; the chain re-derives chain_id and mpk_g2 from committed state and rejects mismatches with AadMismatch). The endianness split (chain_id big-endian; version/wrap_epoch little-endian; mpk_g2 raw) is load-bearing and wire-breaking to change. Single-version decision: the DST stays cbss/ibe/v1 — there is deliberately no v2/v3 DST tree. The interim COW-2660 chain-bound layout and the COW-2923 draft never shipped to a live boundary, and this layout can only be introduced on a wiping re-genesis (the stored secret value envelope binds the 172-byte base_aad, not just the DEK wrap, so a base-AAD change invalidates the ciphertext of every secret value and there is no re-wrap path without the plaintext — §3.4.3 step 5); with state wiped there is nothing to disambiguate, so a single unambiguous v1 is the whole version tree, and the cross-repo drift tripwire is the golden-vector byte-gate rather than a DST bump. Scope: time-lock release / CIP-34 sealed bids (cbss/tlock/v1, base AAD tag ‖ u64_le(target_height)) and CIP-9 volume DEKs (cbss/ibe/cip9-volume-dek/v1, 32-byte base AAD) are explicitly out — each keeps its own per-family base AAD and does not gain chain_id or mpk_g2 (§3.4.6, §9.3). CIP-7 wrapped-DEK AADs bind release_key_material_hash (COW-2924) and keep DST cip-7-wd-v1 (CIP-7 §Content-key Identity). Reference implementation: the merged foundation crate cowboyinc/cowboy-protocol crates/cowboy-protocol-cbss-crypto (aad.rs, identity.rs, cip7.rs), which is the single canonical source the node, cbss, and SDK builders delegate to; this docs amendment is byte-consistent with, and does not gate, that kernel.
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. The cryptographic-engineering rule “don’t roll your own crypto” trumps the curve-reuse argument that originally motivated secp256k1.
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
Three candidate granularities: per-secret, per-actor, per-account. We picked per-account default + per-secret override.- 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.6 Why CIP-24, not CIP-23
CIP-23 is occupied by TEE Execution (Created 2026-04-20). Earlier design notes proposed CIP-23 for the secrets manager because that slot was free at design-time; that proposal is now stale. CIP-24 is the next sequential available slot. Reusing freed slots (CIP-8, CIP-17, CIP-19) creates onboarding confusion with old links and PRs.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.

