scrolls_bitcoin: vetKD-derived canister-specific secret prefix for address derivation#189
scrolls_bitcoin: vetKD-derived canister-specific secret prefix for address derivation#189imikushin wants to merge 6 commits into
Conversation
…for address derivation Bootstraps a 32-byte secret via `vetkd_derive_key` on first signing call: sample `transport_sk` from `raw_rand`, derive transport pk = sk·G1, call vetKD, and store `SHA-256(sk_bytes || encrypted_key)` directly in stable memory. RAM cache (`thread_local!`) is restored from stable memory in `post_upgrade`; no `pre_upgrade` hook (avoids the upgrade-bricking risk). `derivation_path_for_output` now prepends the secret prefix before `SCROLLS`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR updates scrolls_bitcoin address derivation to incorporate a canister-specific secret prefix derived via ICP vetKD, so derived Bitcoin addresses become unique per canister and stable across upgrades (via stable-memory persistence).
Changes:
- Derive and cache a 32-byte canister-specific secret prefix on first use via
raw_rand+vetkd_derive_key, persist it in stable memory, and restore it on upgrade. - Prepend the cached prefix to the ECDSA derivation path used for per-output address/key derivation.
- Add
arkworksdependencies needed to compute the BLS12-381 transport public key for vetKD.
Reviewed changes
Copilot reviewed 2 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
scrolls/src/scrolls_bitcoin/src/lib.rs |
Implements vetKD-based prefix derivation, stable-memory persistence, RAM cache, and derivation-path changes. |
scrolls/src/scrolls_bitcoin/Cargo.toml |
Adds arkworks crates used for BLS12-381 scalar/curve operations and serialization. |
scrolls/Cargo.lock |
Records the new direct dependencies for scrolls_bitcoin. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| async fn ensure_secret_prefix() -> anyhow::Result<()> { | ||
| if SECRET_PREFIX.with(|cell| cell.borrow().is_some()) { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| let rand = raw_rand() | ||
| .await | ||
| .map_err(|e| anyhow!("System error: raw_rand failed: {}", e))?; | ||
| let transport_sk = Fr::from_le_bytes_mod_order(&rand); | ||
| let transport_pk = G1Projective::generator() * transport_sk; | ||
| let mut transport_pk_bytes = Vec::new(); | ||
| transport_pk | ||
| .into_affine() | ||
| .serialize_compressed(&mut transport_pk_bytes) | ||
| .context("System error: serializing transport public key")?; | ||
|
|
||
| let args = VetKDDeriveKeyArgs { | ||
| input: VETKD_SECRET_PREFIX_INPUT.to_vec(), | ||
| context: VETKD_SECRET_PREFIX_CONTEXT.to_vec(), | ||
| transport_public_key: transport_pk_bytes, | ||
| key_id: vetkd_key_id(), | ||
| }; | ||
| let result = vetkd_derive_key(&args) | ||
| .await | ||
| .map_err(|e| anyhow!("System error: vetkd_derive_key failed: {}", e))?; |
Greptile SummaryThis PR bootstraps a 32-byte canister-specific secret prefix via ICP vetKeys and prepends it to every Bitcoin address derivation path, preventing key reuse across separately-deployed canisters. The prefix is derived once on first run, persisted to stable memory using a write-data-before-marker pattern, and cached in an
Confidence Score: 5/5Safe to merge; the timer-only bootstrapping and OnceLock cache together close the concurrency and stale-prefix concerns raised in prior reviews. The address derivation is guarded by require_secret_prefix() at both entry points. Only a single timer drives ensure_secret_prefix; user calls never trigger it, so there is no concurrent derivation path. OnceLock immutability means cached_secret_prefix() cannot return a stale or overwritten value mid-loop. The write-data-before-marker pattern in store_persisted_prefix ensures a torn write leaves the marker unset, causing a safe re-derive on the next attempt. The exponential-backoff retry covers transient vetKD failures without manual intervention. No files require special attention. The raw stable-memory region (bytes 0-32) is the only area to watch in future upgrades that add more persistent state - a comment already documents this layout. Important Files Changed
Reviews (6): Last reviewed commit: "refactor(scrolls_bitcoin): retry secret-..." | Re-trigger Greptile |
…nister via vetKD decryption
Switch from the throwaway-transport-secret design to a fully deterministic
vetKD flow using the official `ic-vetkeys` crate:
1. Seed the transport secret key from a BIP-340 Schnorr signature over a
fixed message under a dedicated derivation path — only this canister
can produce it, and the signature is deterministic per (chain key,
message), so the seed is reproducible.
2. Build a `TransportSecretKey` from that seed, call `vetkd_derive_key`
and `vetkd_public_key`, then decrypt-and-verify the ciphertext.
3. The decrypted vetKey is deterministic per (canister_id, input,
context) by design, so the resulting 32-byte secret prefix (HKDF
domain-separated) can always be regenerated by this canister.
Drops the direct `ark-*` deps in favor of `ic-vetkeys`, which encapsulates
all the BLS12-381 IBE machinery. Stable-memory cache and lack of
`pre_upgrade` hook are unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…et prefix The decrypted vetKey is canonical for (canister_id, input, context) — the transport keypair only wraps it in transit. So a fresh `raw_rand` seed on every call yields the same vetKey after decryption, and the secret prefix remains reproducible without needing a deterministic transport sk. Drops the Schnorr-signature seeding ceremony and its dedicated derivation path / message constants. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cache has write-once, read-many semantics and IC canisters execute single-threaded per message, so `thread_local! RefCell<Option<...>>` was overkill. A plain `static OnceLock<[u8; SECRET_PREFIX_LEN]>` matches the use exactly: cheap reads, `set()` tolerates the (impossible-in-practice) double-init race, and no closure boilerplate at the call sites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, fail fast on calls Lifecycle hooks can't make inter-canister calls, so schedule `ensure_secret_prefix` on a `Duration::ZERO` timer from `do_init` (covers both `init` and `post_upgrade`). The timer's first action is to read the prefix from stable memory if present — only on a fresh canister does it actually call vetKD. `addresses` and `sign_and_submit` no longer try to lazy-init themselves; they call `require_secret_prefix` up front and return a clear "service unavailable, retry shortly" error if the timer hasn't completed yet. This decouples the hot signing path from any vetKD latency and keeps each call's blast radius small. Adds `ic-cdk-timers = "1.0"`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tial backoff If `ensure_secret_prefix` fails (transient subnet error, vetKD outage, etc.), reschedule the timer with a doubling delay (1s, 2s, 4s, …, capped at one hour) instead of leaving the canister unable to sign forever. The chain stops on success because the cache short-circuit at the top of `ensure_secret_prefix` turns any subsequent call into a no-op. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
vetkd_derive_key) on the first signing call: samplestransport_skfromraw_rand, derivestransport_pk = sk · G1on BLS12-381, calls vetKD, and usesSHA-256(sk_bytes || encrypted_key)as the prefix.transport_skis dropped — nobody, including this canister, can decrypt the ciphertext after.thread_local!RAM cache for cheap reads on the hot path.pre_upgradehook. Stable memory is written at derive time, not at upgrade time — apre_upgradetrap would brick the canister; apost_upgradefailure is always recoverable by another upgrade.derivation_path_for_outputbeforeSCROLLS, so every derivation path becomes[secret_prefix, "scrolls", tx_in_0_bytes, out_i_le_bytes].addresses_implanddo_signbothawait ensure_secret_prefix()before any derivation.ark-bls12-381,ark-ec,ark-ff,ark-serialize(all already in the workspace lockfile viacharms-client, so no new transitive crates).scrolls_bitcoingoing forward. Existing UTXOs derived under the old[SCROLLS, ...]path will no longer match this canister's derivation — requires a fresh deploy / new canister version.Test plan
cargo check --package scrolls_bitcoincleancargo check --package scrolls_bitcoin --target wasm32-unknown-unknowncleancargo test --package scrolls_bitcoinpassesaddressesand confirm a 32-byte prefix is persisted to stable memorypost_upgrade(addresses stay stable across the upgrade)addressesreturns the same address for the same(tx_in_0, out_i)on repeated calls, but a different address than a freshly-installed canister🤖 Generated with Claude Code