From ba5f4600899b2ece381148bacc968fbc517cd4fe Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Tue, 14 Apr 2026 13:16:37 -0700 Subject: [PATCH 1/6] Implement slip-0010 and seed-curve arg --- CHANGELOG.md | 1 + Cargo.lock | 1 + Cargo.toml | 1 + .../icp-cli/src/commands/identity/import.rs | 24 ++- crates/icp-cli/src/commands/identity/new.rs | 7 +- crates/icp-cli/tests/identity_tests.rs | 36 +++++ crates/icp/Cargo.toml | 1 + crates/icp/src/identity/seed.rs | 13 -- crates/icp/src/identity/seed/mod.rs | 45 ++++++ crates/icp/src/identity/seed/slip10.rs | 137 ++++++++++++++++++ docs/reference/cli.md | 6 + 11 files changed, 248 insertions(+), 24 deletions(-) delete mode 100644 crates/icp/src/identity/seed.rs create mode 100644 crates/icp/src/identity/seed/mod.rs create mode 100644 crates/icp/src/identity/seed/slip10.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e40b08e2..929f0cc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Unreleased +* feat: `icp identity import` now takes `--seed-curve`, for seed phrases for non-k256 keys. * fix: `icp canister settings show` now outputs only the canister settings, consistent with the command name # v0.2.3 diff --git a/Cargo.lock b/Cargo.lock index 8e8952a4..b7cb9864 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3321,6 +3321,7 @@ dependencies = [ "glob", "handlebars", "hex", + "hmac", "ic-agent", "ic-asset", "ic-ed25519", diff --git a/Cargo.toml b/Cargo.toml index 7477930a..4dcf8bda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ futures = "0.3.31" glob = "0.3.2" handlebars = "6.3.2" hex = { version = "0.4.3", features = ["serde"] } +hmac = "0.12" httptest = "0.16.3" ic-agent = { version = "0.47.0" } ic-asset = "0.29.0" diff --git a/crates/icp-cli/src/commands/identity/import.rs b/crates/icp-cli/src/commands/identity/import.rs index 1b901af4..452e8dac 100644 --- a/crates/icp-cli/src/commands/identity/import.rs +++ b/crates/icp-cli/src/commands/identity/import.rs @@ -5,7 +5,7 @@ use elliptic_curve::zeroize::Zeroizing; use icp::identity::{ key::{CreateFormat, CreateIdentityError, IdentityKey, create_identity}, manifest::IdentityKeyAlgorithm, - seed::derive_default_key_from_seed, + seed::derive_key_from_seed_slip10, }; use icp::{fs::read_to_string, prelude::*}; use itertools::Itertools; @@ -26,7 +26,10 @@ use crate::commands::identity::StorageMode; /// Import a new identity #[derive(Debug, Args)] -#[command(group(ArgGroup::new("import-from").required(true)))] +#[command( + group(ArgGroup::new("import-from").required(true)), + group(ArgGroup::new("seed").required(false)), +)] pub(crate) struct ImportArgs { /// Name for the imported identity name: String, @@ -40,11 +43,11 @@ pub(crate) struct ImportArgs { from_pem: Option, /// Read seed phrase interactively from the terminal - #[arg(long, group = "import-from")] + #[arg(long, group = "import-from", group = "seed")] read_seed_phrase: bool, /// Read seed phrase from a file - #[arg(long, value_name = "FILE", group = "import-from")] + #[arg(long, value_name = "FILE", group = "import-from", group = "seed")] from_seed_file: Option, /// Read the PEM decryption password from a file instead of prompting @@ -58,6 +61,10 @@ pub(crate) struct ImportArgs { /// Specify the key type when it cannot be detected from the PEM file (danger!) #[arg(long, value_enum)] assert_key_type: Option, + + /// Curve for SLIP-0010 key derivation from a seed phrase + #[arg(long, value_enum, default_value_t = IdentityKeyAlgorithm::Secp256k1, requires = "seed")] + seed_curve: IdentityKeyAlgorithm, } pub(crate) async fn exec(ctx: &Context, args: &ImportArgs) -> Result<(), anyhow::Error> { @@ -94,14 +101,14 @@ pub(crate) async fn exec(ctx: &Context, args: &ImportArgs) -> Result<(), anyhow: .await?; } else if let Some(path) = &args.from_seed_file { let phrase = read_to_string(path).context(ReadSeedFileSnafu)?; - import_from_seed_phrase(ctx, &args.name, &phrase, format).await?; + import_from_seed_phrase(ctx, &args.name, &phrase, args.seed_curve.clone(), format).await?; } else if args.read_seed_phrase { let phrase = Password::new() .with_prompt("Enter seed phrase") .with_confirmation("Re-enter seed phrase", "Seed phrases do not match") .interact() .context(ReadSeedPhraseFromTerminalSnafu)?; - import_from_seed_phrase(ctx, &args.name, &phrase, format).await?; + import_from_seed_phrase(ctx, &args.name, &phrase, args.seed_curve.clone(), format).await?; } else { unreachable!(); } @@ -371,13 +378,14 @@ async fn import_from_seed_phrase( ctx: &Context, name: &str, phrase: &str, + algorithm: IdentityKeyAlgorithm, format: CreateFormat, ) -> Result<(), DeriveKeyError> { let mnemonic = Mnemonic::from_phrase(phrase, Language::English).context(ParseMnemonicSnafu)?; - let key = derive_default_key_from_seed(&mnemonic); + let key = derive_key_from_seed_slip10(&mnemonic, &algorithm); ctx.dirs .identity()? - .with_write(async |dirs| create_identity(dirs, name, IdentityKey::Secp256k1(key), format)) + .with_write(async move |dirs| create_identity(dirs, name, key, format)) .await??; Ok(()) } diff --git a/crates/icp-cli/src/commands/identity/new.rs b/crates/icp-cli/src/commands/identity/new.rs index 7dd3ee90..f232a3c7 100644 --- a/crates/icp-cli/src/commands/identity/new.rs +++ b/crates/icp-cli/src/commands/identity/new.rs @@ -8,8 +8,9 @@ use elliptic_curve::zeroize::Zeroizing; use icp::{ fs::write_string, identity::{ - key::{CreateFormat, IdentityKey, create_identity, validate_password}, - seed::derive_default_key_from_seed, + key::{CreateFormat, create_identity, validate_password}, + manifest::IdentityKeyAlgorithm, + seed::derive_key_from_seed_slip10, }, prelude::*, }; @@ -81,7 +82,7 @@ pub(crate) async fn exec(ctx: &Context, args: &NewArgs) -> Result<(), anyhow::Er create_identity( dirs, &args.name, - IdentityKey::Secp256k1(derive_default_key_from_seed(&mnemonic)), + derive_key_from_seed_slip10(&mnemonic, &IdentityKeyAlgorithm::Secp256k1), format, ) }) diff --git a/crates/icp-cli/tests/identity_tests.rs b/crates/icp-cli/tests/identity_tests.rs index 400ff782..73db0ada 100644 --- a/crates/icp-cli/tests/identity_tests.rs +++ b/crates/icp-cli/tests/identity_tests.rs @@ -56,6 +56,42 @@ fn identity_import_seed() { .stdout(eq("5upke-tazvi-6ufqc-i3v6r-j4gpu-dpwti-obhal-yb5xj-ue32x-ktkql-rqe").trim()); } +#[test] +fn identity_import_seed_curve() { + // Seed: "equip will roof matter pink blind book anxiety banner elbow sun young" + // p256: SLIP-0010 "Nist256p1 seed", path m/44'/223'/0'/0/0 + // ed25519: SLIP-0010 "ed25519 seed", path m/44'/223'/0'/0'/0' + let mut file = NamedTempFile::new().unwrap(); + file.write_all(b"equip will roof matter pink blind book anxiety banner elbow sun young") + .unwrap(); + let path = file.into_temp_path(); + let ctx = TestContext::new(); + + ctx.icp() + .args(["identity", "import", "alice_p256", "--from-seed-file"]) + .arg(&path) + .args(["--seed-curve", "prime256v1"]) + .assert() + .success(); + ctx.icp() + .args(["identity", "principal", "--identity", "alice_p256"]) + .assert() + .success() + .stdout(eq("gu6g3-gzs4p-fyjio-reppd-qk7ef-lhput-eg36s-ofyim-gi6y4-ce3qs-zqe").trim()); + + ctx.icp() + .args(["identity", "import", "alice_ed25519", "--from-seed-file"]) + .arg(&path) + .args(["--seed-curve", "ed25519"]) + .assert() + .success(); + ctx.icp() + .args(["identity", "principal", "--identity", "alice_ed25519"]) + .assert() + .success() + .stdout(eq("z2yk5-gbsi4-5eudl-y5q6u-qaqmf-37gjy-r66iy-oiqvb-d5nbr-5odxa-4qe").trim()); +} + #[test] fn identity_import_pem() { // from plaintext sec1 diff --git a/crates/icp/Cargo.toml b/crates/icp/Cargo.toml index 66809c58..24bbe447 100644 --- a/crates/icp/Cargo.toml +++ b/crates/icp/Cargo.toml @@ -26,6 +26,7 @@ futures = { workspace = true } glob = { workspace = true } handlebars = { workspace = true } hex = { workspace = true } +hmac = { workspace = true } ic-agent = { workspace = true } ic-asset = { workspace = true } ic-ed25519 = { workspace = true } diff --git a/crates/icp/src/identity/seed.rs b/crates/icp/src/identity/seed.rs deleted file mode 100644 index f4778e1e..00000000 --- a/crates/icp/src/identity/seed.rs +++ /dev/null @@ -1,13 +0,0 @@ -use bip32::{DerivationPath, XPrv}; -use bip39::{Mnemonic, Seed}; - -pub fn default_derivation_path() -> DerivationPath { - "m/44'/223'/0'/0/0".parse().expect("valid derivation path") -} - -pub fn derive_default_key_from_seed(mnemonic: &Mnemonic) -> k256::SecretKey { - let seed = Seed::new(mnemonic, ""); - let pk = XPrv::derive_from_path(seed.as_bytes(), &default_derivation_path()) - .expect("valid derivation"); - k256::SecretKey::from(pk.private_key()) -} diff --git a/crates/icp/src/identity/seed/mod.rs b/crates/icp/src/identity/seed/mod.rs new file mode 100644 index 00000000..a862de4a --- /dev/null +++ b/crates/icp/src/identity/seed/mod.rs @@ -0,0 +1,45 @@ +use bip32::DerivationPath; +use bip39::{Mnemonic, Seed}; + +use super::{key::IdentityKey, manifest::IdentityKeyAlgorithm}; + +pub mod slip10; + +/// Standard ICP derivation path for secp256k1 and p256 (coin type 223). +pub fn default_derivation_path() -> DerivationPath { + "m/44'/223'/0'/0/0".parse().expect("valid derivation path") +} + +/// All-hardened ICP derivation path for ed25519; SLIP-0010 requires all path +/// components to be hardened for ed25519. +pub fn ed25519_derivation_path() -> DerivationPath { + "m/44'/223'/0'/0'/0'" + .parse() + .expect("valid derivation path") +} + +/// Derives a key from a BIP-39 mnemonic using SLIP-0010 for the given curve. +/// +/// - `Secp256k1`: path `m/44'/223'/0'/0/0` +/// - `Prime256v1`: path `m/44'/223'/0'/0/0` +/// - `Ed25519`: path `m/44'/223'/0'/0'/0'` (all hardened) +pub fn derive_key_from_seed_slip10( + mnemonic: &Mnemonic, + algorithm: &IdentityKeyAlgorithm, +) -> IdentityKey { + let seed = Seed::new(mnemonic, ""); + match algorithm { + IdentityKeyAlgorithm::Secp256k1 => IdentityKey::Secp256k1(slip10::derive_secp256k1( + seed.as_bytes(), + &default_derivation_path(), + )), + IdentityKeyAlgorithm::Prime256v1 => IdentityKey::Prime256v1(slip10::derive_p256( + seed.as_bytes(), + &default_derivation_path(), + )), + IdentityKeyAlgorithm::Ed25519 => IdentityKey::Ed25519(slip10::derive_ed25519( + seed.as_bytes(), + &ed25519_derivation_path(), + )), + } +} diff --git a/crates/icp/src/identity/seed/slip10.rs b/crates/icp/src/identity/seed/slip10.rs new file mode 100644 index 00000000..e9eecf6d --- /dev/null +++ b/crates/icp/src/identity/seed/slip10.rs @@ -0,0 +1,137 @@ +//! SLIP-0010 hierarchical deterministic key derivation. +//! +//! Implements + +use bip32::DerivationPath; +use elliptic_curve::{Curve, bigint::Encoding, sec1::ToEncodedPoint}; +use hmac::{Hmac, Mac}; +use num_bigint::BigUint; +use num_traits::Zero; +use sha2::Sha512; + +type HmacSha512 = Hmac; + +const SECP256K1_SEED_KEY: &[u8] = b"Bitcoin seed"; +const P256_SEED_KEY: &[u8] = b"Nist256p1 seed"; +const ED25519_SEED_KEY: &[u8] = b"ed25519 seed"; + +pub fn derive_secp256k1(seed: &[u8], path: &DerivationPath) -> k256::SecretKey { + let key_bytes = slip10_derive( + seed, + path, + SECP256K1_SEED_KEY, + Some(k256::Secp256k1::ORDER.to_be_bytes()), + k256_compressed_public_key, + ); + k256::SecretKey::from_slice(&key_bytes) + .expect("SLIP-0010 secp256k1 derivation produced a valid key") +} + +pub fn derive_p256(seed: &[u8], path: &DerivationPath) -> p256::SecretKey { + let key_bytes = slip10_derive( + seed, + path, + P256_SEED_KEY, + Some(p256::NistP256::ORDER.to_be_bytes()), + p256_compressed_public_key, + ); + p256::SecretKey::from_slice(&key_bytes).expect("SLIP-0010 p256 derivation produced a valid key") +} + +/// Panics if any path component is non-hardened; SLIP-0010 forbids it for Ed25519. +pub fn derive_ed25519(seed: &[u8], path: &DerivationPath) -> ic_ed25519::PrivateKey { + let key_bytes = slip10_derive(seed, path, ED25519_SEED_KEY, None, |_| unreachable!()); + ic_ed25519::PrivateKey::deserialize_raw(&key_bytes) + .expect("SLIP-0010 ed25519 derivation produced a valid key") +} + +fn hmac_sha512_split(key: &[u8], data: &[u8]) -> ([u8; 32], [u8; 32]) { + let mut mac = HmacSha512::new_from_slice(key).expect("HMAC accepts any key length"); + mac.update(data); + let out = mac.finalize().into_bytes(); + (out[..32].try_into().unwrap(), out[32..].try_into().unwrap()) +} + +/// Generic SLIP-0010 derivation. +/// +/// `order` is `Some(n)` for EC curves (secp256k1, P-256), where child keys are +/// derived via modular scalar addition. Pass `None` for Ed25519, which uses the +/// left 32 HMAC bytes directly and requires all path components to be hardened. +/// +/// `pub_key` computes the compressed public key for non-hardened child steps; +/// it is never called when `order` is `None`. +fn slip10_derive( + seed: &[u8], + path: &DerivationPath, + curve_key: &[u8], + order: Option<[u8; 32]>, + pub_key: impl Fn(&[u8; 32]) -> [u8; 33], +) -> [u8; 32] { + let (mut key, mut chain_code) = hmac_sha512_split(curve_key, seed); + + for child in path.iter() { + assert!( + order.is_some() || child.is_hardened(), + "SLIP-0010 {}: all path components must be hardened, but {child} is non-hardened", + std::str::from_utf8(curve_key).unwrap_or("?"), + ); + + let raw_index = u32::from(child); + let mut data = Vec::with_capacity(37); + if child.is_hardened() { + data.push(0x00); + data.extend_from_slice(&key); + } else { + data.extend_from_slice(&pub_key(&key)); + } + data.extend_from_slice(&raw_index.to_be_bytes()); + + let (il, ir) = hmac_sha512_split(&chain_code, &data); + + if let Some(order_bytes) = order { + // EC: child key = (IL + parent_key) mod n + let order = BigUint::from_bytes_be(&order_bytes); + let il_big = BigUint::from_bytes_be(&il); + assert!( + il_big < order, + "SLIP-0010: IL >= order at index {child} (astronomically unlikely)" + ); + let child_big = (il_big + BigUint::from_bytes_be(&key)) % ℴ + assert!( + !child_big.is_zero(), + "SLIP-0010: child key is zero at index {child} (astronomically unlikely)" + ); + let child_bytes = child_big.to_bytes_be(); + key = [0u8; 32]; + key[32 - child_bytes.len()..].copy_from_slice(&child_bytes); + chain_code = ir; + } else { + // Ed25519: child key is the left 32 bytes directly; no modular arithmetic. + (key, chain_code) = (il, ir); + } + } + + key +} + +/// Returns the compressed SEC1 public key (33 bytes) for a secp256k1 private key scalar. +fn k256_compressed_public_key(key_bytes: &[u8; 32]) -> [u8; 33] { + let secret = k256::SecretKey::from_slice(key_bytes).expect("valid k256 secret key"); + secret + .public_key() + .to_encoded_point(true) + .as_bytes() + .try_into() + .expect("compressed k256 point is 33 bytes") +} + +/// Returns the compressed SEC1 public key (33 bytes) for a p256 private key scalar. +fn p256_compressed_public_key(key_bytes: &[u8; 32]) -> [u8; 33] { + let secret = p256::SecretKey::from_slice(key_bytes).expect("valid p256 secret key"); + secret + .public_key() + .to_encoded_point(true) + .as_bytes() + .try_into() + .expect("compressed p256 point is 33 bytes") +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d3b1378a..207e203a 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1001,6 +1001,12 @@ Import a new identity Possible values: `secp256k1`, `prime256v1`, `ed25519` +* `--seed-curve ` — Curve for SLIP-0010 key derivation from a seed phrase + + Default value: `secp256k1` + + Possible values: `secp256k1`, `prime256v1`, `ed25519` + From 1ddc7a9150215258928e023b698ac2222f9aea90 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Wed, 15 Apr 2026 11:00:21 -0700 Subject: [PATCH 2/6] Replace edge case asserts with logic --- crates/icp/src/identity/seed/slip10.rs | 65 ++++++++++++++------------ 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/crates/icp/src/identity/seed/slip10.rs b/crates/icp/src/identity/seed/slip10.rs index e9eecf6d..88dab58a 100644 --- a/crates/icp/src/identity/seed/slip10.rs +++ b/crates/icp/src/identity/seed/slip10.rs @@ -76,38 +76,43 @@ fn slip10_derive( std::str::from_utf8(curve_key).unwrap_or("?"), ); - let raw_index = u32::from(child); - let mut data = Vec::with_capacity(37); - if child.is_hardened() { - data.push(0x00); - data.extend_from_slice(&key); - } else { - data.extend_from_slice(&pub_key(&key)); - } - data.extend_from_slice(&raw_index.to_be_bytes()); + // Per spec, on an invalid derived key (IL >= n or child == 0), retry with index + 1. + // The hardened flag lives in bit 31, so incrementing preserves it. + let mut index = u32::from(child); + loop { + let mut data = Vec::with_capacity(37); + if index >= 0x8000_0000 { + data.push(0x00); + data.extend_from_slice(&key); + } else { + data.extend_from_slice(&pub_key(&key)); + } + data.extend_from_slice(&index.to_be_bytes()); - let (il, ir) = hmac_sha512_split(&chain_code, &data); + let (il, ir) = hmac_sha512_split(&chain_code, &data); - if let Some(order_bytes) = order { - // EC: child key = (IL + parent_key) mod n - let order = BigUint::from_bytes_be(&order_bytes); - let il_big = BigUint::from_bytes_be(&il); - assert!( - il_big < order, - "SLIP-0010: IL >= order at index {child} (astronomically unlikely)" - ); - let child_big = (il_big + BigUint::from_bytes_be(&key)) % ℴ - assert!( - !child_big.is_zero(), - "SLIP-0010: child key is zero at index {child} (astronomically unlikely)" - ); - let child_bytes = child_big.to_bytes_be(); - key = [0u8; 32]; - key[32 - child_bytes.len()..].copy_from_slice(&child_bytes); - chain_code = ir; - } else { - // Ed25519: child key is the left 32 bytes directly; no modular arithmetic. - (key, chain_code) = (il, ir); + if let Some(order_bytes) = order { + // EC: child key = (IL + parent_key) mod n + let order = BigUint::from_bytes_be(&order_bytes); + let il_big = BigUint::from_bytes_be(&il); + if il_big >= order { + index += 1; + continue; + } + let child_big = (il_big + BigUint::from_bytes_be(&key)) % ℴ + if child_big.is_zero() { + index += 1; + continue; + } + let child_bytes = child_big.to_bytes_be(); + key = [0u8; 32]; + key[32 - child_bytes.len()..].copy_from_slice(&child_bytes); + chain_code = ir; + } else { + // Ed25519: child key is the left 32 bytes directly; no modular arithmetic. + (key, chain_code) = (il, ir); + } + break; } } From 7a48888fbc274e43d2dfcd2c0a3e2cf7518e92a3 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Wed, 15 Apr 2026 13:06:37 -0700 Subject: [PATCH 3/6] zeroize --- Cargo.lock | 107 ++++++++++++++++++++++--- Cargo.toml | 6 +- crates/icp/Cargo.toml | 2 + crates/icp/src/identity/seed/slip10.rs | 71 ++++++++++------ 4 files changed, 147 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b7cb9864..912d87e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -618,7 +618,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db40d3dfbeab4e031d78c844642fa0caa0b0db11ce1607ac9d2986dff1405c69" dependencies = [ "bs58", - "hmac", + "hmac 0.12.1", "k256", "once_cell", "pbkdf2", @@ -702,6 +702,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", + "zeroize", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -1144,7 +1154,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -1207,6 +1217,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "codespan-reporting" version = "0.11.1" @@ -1273,6 +1289,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -1417,6 +1439,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cryptoki" version = "0.12.0" @@ -1450,6 +1481,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1577,7 +1617,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] @@ -1679,11 +1719,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid", - "crypto-common", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.1", + "ctutils", + "zeroize", +] + [[package]] name = "directories" version = "6.0.0" @@ -2787,7 +2840,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", ] [[package]] @@ -2799,6 +2852,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.2", +] + [[package]] name = "home" version = "0.5.12" @@ -2889,6 +2951,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", + "zeroize", +] + [[package]] name = "hyper" version = "1.8.1" @@ -3311,6 +3383,7 @@ dependencies = [ "candid", "candid_parser", "clap", + "crypto-bigint", "directories", "dunce", "ed25519-consensus", @@ -3321,7 +3394,8 @@ dependencies = [ "glob", "handlebars", "hex", - "hmac", + "hmac 0.13.0", + "hybrid-array", "ic-agent", "ic-asset", "ic-ed25519", @@ -3352,7 +3426,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "sha2 0.10.9", + "sha2 0.11.0", "shellwords", "slog", "snafu", @@ -3439,7 +3513,7 @@ dependencies = [ "serde_json", "serde_yaml", "serial_test", - "sha2 0.10.9", + "sha2 0.11.0", "shellwords", "snafu", "sysinfo", @@ -4841,7 +4915,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac", + "hmac 0.12.1", ] [[package]] @@ -5605,7 +5679,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] @@ -6286,6 +6360,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", +] + [[package]] name = "sharded-slab" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index 4dcf8bda..73a3848c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ dialoguer = "0.12.0" directories = "6.0.0" dunce = "1.0.5" ed25519-consensus = "2.1.0" +crypto-bigint = { version = "0.5.5", features = ["zeroize"] } elliptic-curve = { version = "0.13.8", features = ["sec1", "std", "pkcs8"] } fd-lock = "4.0.4" flate2 = { version = "1.1.8", default-features = false, features = ["zlib-rs"] } @@ -45,7 +46,8 @@ futures = "0.3.31" glob = "0.3.2" handlebars = "6.3.2" hex = { version = "0.4.3", features = ["serde"] } -hmac = "0.12" +hmac = { version = "0.13", features = ["zeroize"] } +hybrid-array = { version = "0.4.10", features = ["zeroize"] } httptest = "0.16.3" ic-agent = { version = "0.47.0" } ic-asset = "0.29.0" @@ -88,7 +90,7 @@ serial_test = { version = "3.2.0", features = ["file_locks"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml = "0.9.34" -sha2 = "0.10.9" +sha2 = { version = "0.11.0", features = ["zeroize"] } shellwords = "1.1.0" slog = "2.7.0" snafu = "0.9.0" diff --git a/crates/icp/Cargo.toml b/crates/icp/Cargo.toml index 24bbe447..3fbf0173 100644 --- a/crates/icp/Cargo.toml +++ b/crates/icp/Cargo.toml @@ -14,6 +14,7 @@ bollard = { workspace = true } camino = { workspace = true } camino-tempfile = { workspace = true } candid = { workspace = true } +crypto-bigint = { workspace = true } candid_parser = { workspace = true } clap = { workspace = true, optional = true } directories = { workspace = true } @@ -27,6 +28,7 @@ glob = { workspace = true } handlebars = { workspace = true } hex = { workspace = true } hmac = { workspace = true } +hybrid-array = { workspace = true } ic-agent = { workspace = true } ic-asset = { workspace = true } ic-ed25519 = { workspace = true } diff --git a/crates/icp/src/identity/seed/slip10.rs b/crates/icp/src/identity/seed/slip10.rs index 88dab58a..a6bdd5e6 100644 --- a/crates/icp/src/identity/seed/slip10.rs +++ b/crates/icp/src/identity/seed/slip10.rs @@ -3,11 +3,11 @@ //! Implements use bip32::DerivationPath; -use elliptic_curve::{Curve, bigint::Encoding, sec1::ToEncodedPoint}; -use hmac::{Hmac, Mac}; -use num_bigint::BigUint; -use num_traits::Zero; +use crypto_bigint::{Encoding, U256, Zero}; +use elliptic_curve::{Curve, sec1::ToEncodedPoint}; +use hmac::{Hmac, KeyInit, Mac}; use sha2::Sha512; +use zeroize::{ZeroizeOnDrop, Zeroizing}; type HmacSha512 = Hmac; @@ -20,10 +20,10 @@ pub fn derive_secp256k1(seed: &[u8], path: &DerivationPath) -> k256::SecretKey { seed, path, SECP256K1_SEED_KEY, - Some(k256::Secp256k1::ORDER.to_be_bytes()), + Some(k256::Secp256k1::ORDER), k256_compressed_public_key, ); - k256::SecretKey::from_slice(&key_bytes) + k256::SecretKey::from_slice(&*key_bytes) .expect("SLIP-0010 secp256k1 derivation produced a valid key") } @@ -32,24 +32,29 @@ pub fn derive_p256(seed: &[u8], path: &DerivationPath) -> p256::SecretKey { seed, path, P256_SEED_KEY, - Some(p256::NistP256::ORDER.to_be_bytes()), + Some(p256::NistP256::ORDER), p256_compressed_public_key, ); - p256::SecretKey::from_slice(&key_bytes).expect("SLIP-0010 p256 derivation produced a valid key") + p256::SecretKey::from_slice(&*key_bytes) + .expect("SLIP-0010 p256 derivation produced a valid key") } /// Panics if any path component is non-hardened; SLIP-0010 forbids it for Ed25519. pub fn derive_ed25519(seed: &[u8], path: &DerivationPath) -> ic_ed25519::PrivateKey { let key_bytes = slip10_derive(seed, path, ED25519_SEED_KEY, None, |_| unreachable!()); - ic_ed25519::PrivateKey::deserialize_raw(&key_bytes) + ic_ed25519::PrivateKey::deserialize_raw(&*key_bytes) .expect("SLIP-0010 ed25519 derivation produced a valid key") } -fn hmac_sha512_split(key: &[u8], data: &[u8]) -> ([u8; 32], [u8; 32]) { +fn hmac_sha512_split(key: &[u8], data: &[u8]) -> (Zeroizing<[u8; 32]>, Zeroizing<[u8; 32]>) { + assert_zeroize_enabled::(); // HmacSha512 doesn't implement ZeroizeOnDrop for some reason but it contains Sha512 which does. let mut mac = HmacSha512::new_from_slice(key).expect("HMAC accepts any key length"); mac.update(data); - let out = mac.finalize().into_bytes(); - (out[..32].try_into().unwrap(), out[32..].try_into().unwrap()) + let out = Zeroizing::new(mac.finalize().into_bytes()); + ( + Zeroizing::new(out[..32].try_into().unwrap()), + Zeroizing::new(out[32..].try_into().unwrap()), + ) } /// Generic SLIP-0010 derivation. @@ -64,11 +69,20 @@ fn slip10_derive( seed: &[u8], path: &DerivationPath, curve_key: &[u8], - order: Option<[u8; 32]>, + order: Option, pub_key: impl Fn(&[u8; 32]) -> [u8; 33], -) -> [u8; 32] { +) -> Zeroizing<[u8; 32]> { let (mut key, mut chain_code) = hmac_sha512_split(curve_key, seed); + // For EC curves, the spec requires the master key to be a valid scalar. + if let Some(order) = order { + let master = Zeroizing::new(U256::from_be_bytes(*key)); + assert!( + !bool::from(master.is_zero()) && *master < order, + "SLIP-0010: master key derived from seed is invalid; use a different seed", + ); + } + for child in path.iter() { assert!( order.is_some() || child.is_hardened(), @@ -80,33 +94,34 @@ fn slip10_derive( // The hardened flag lives in bit 31, so incrementing preserves it. let mut index = u32::from(child); loop { - let mut data = Vec::with_capacity(37); + let mut data = Zeroizing::new(Vec::with_capacity(37)); if index >= 0x8000_0000 { data.push(0x00); - data.extend_from_slice(&key); + data.extend_from_slice(&*key); } else { data.extend_from_slice(&pub_key(&key)); } data.extend_from_slice(&index.to_be_bytes()); - let (il, ir) = hmac_sha512_split(&chain_code, &data); + let (il, ir) = hmac_sha512_split(&*chain_code, &data); - if let Some(order_bytes) = order { + if let Some(order) = order { // EC: child key = (IL + parent_key) mod n - let order = BigUint::from_bytes_be(&order_bytes); - let il_big = BigUint::from_bytes_be(&il); - if il_big >= order { + let il_scalar = Zeroizing::new(U256::from_be_bytes(*il)); + if *il_scalar >= order { index += 1; continue; } - let child_big = (il_big + BigUint::from_bytes_be(&key)) % ℴ - if child_big.is_zero() { + let key_scalar = Zeroizing::new(U256::from_be_bytes(*key)); + // add_mod requires both operands < order; key_scalar is guaranteed < order + // because it was validated on entry (master key check above) and every + // subsequent value is the output of a prior add_mod call. + let child_scalar = Zeroizing::new(il_scalar.add_mod(&key_scalar, &order)); + if bool::from(child_scalar.is_zero()) { index += 1; continue; } - let child_bytes = child_big.to_bytes_be(); - key = [0u8; 32]; - key[32 - child_bytes.len()..].copy_from_slice(&child_bytes); + *key = child_scalar.to_be_bytes(); chain_code = ir; } else { // Ed25519: child key is the left 32 bytes directly; no modular arithmetic. @@ -121,6 +136,7 @@ fn slip10_derive( /// Returns the compressed SEC1 public key (33 bytes) for a secp256k1 private key scalar. fn k256_compressed_public_key(key_bytes: &[u8; 32]) -> [u8; 33] { + assert_zeroize_enabled::(); let secret = k256::SecretKey::from_slice(key_bytes).expect("valid k256 secret key"); secret .public_key() @@ -132,6 +148,7 @@ fn k256_compressed_public_key(key_bytes: &[u8; 32]) -> [u8; 33] { /// Returns the compressed SEC1 public key (33 bytes) for a p256 private key scalar. fn p256_compressed_public_key(key_bytes: &[u8; 32]) -> [u8; 33] { + assert_zeroize_enabled::(); let secret = p256::SecretKey::from_slice(key_bytes).expect("valid p256 secret key"); secret .public_key() @@ -140,3 +157,5 @@ fn p256_compressed_public_key(key_bytes: &[u8; 32]) -> [u8; 33] { .try_into() .expect("compressed p256 point is 33 bytes") } + +fn assert_zeroize_enabled() {} From ac341bda551dcc8ffef8ea7eb2309f2866b831a7 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Wed, 15 Apr 2026 13:08:38 -0700 Subject: [PATCH 4/6] update webpki for audit --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 912d87e4..e11521df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5878,9 +5878,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ "aws-lc-rs", "ring", From ce8c23cc83b2bc7a7b2e1d1c2e243d23bf632011 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Thu, 16 Apr 2026 20:26:36 -0400 Subject: [PATCH 5/6] Restrict slip10 derive_* functions to seed module Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/icp/src/identity/seed/mod.rs | 2 +- crates/icp/src/identity/seed/slip10.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/icp/src/identity/seed/mod.rs b/crates/icp/src/identity/seed/mod.rs index a862de4a..02fd3627 100644 --- a/crates/icp/src/identity/seed/mod.rs +++ b/crates/icp/src/identity/seed/mod.rs @@ -3,7 +3,7 @@ use bip39::{Mnemonic, Seed}; use super::{key::IdentityKey, manifest::IdentityKeyAlgorithm}; -pub mod slip10; +mod slip10; /// Standard ICP derivation path for secp256k1 and p256 (coin type 223). pub fn default_derivation_path() -> DerivationPath { diff --git a/crates/icp/src/identity/seed/slip10.rs b/crates/icp/src/identity/seed/slip10.rs index a6bdd5e6..a032b134 100644 --- a/crates/icp/src/identity/seed/slip10.rs +++ b/crates/icp/src/identity/seed/slip10.rs @@ -15,7 +15,7 @@ const SECP256K1_SEED_KEY: &[u8] = b"Bitcoin seed"; const P256_SEED_KEY: &[u8] = b"Nist256p1 seed"; const ED25519_SEED_KEY: &[u8] = b"ed25519 seed"; -pub fn derive_secp256k1(seed: &[u8], path: &DerivationPath) -> k256::SecretKey { +pub(super) fn derive_secp256k1(seed: &[u8], path: &DerivationPath) -> k256::SecretKey { let key_bytes = slip10_derive( seed, path, @@ -27,7 +27,7 @@ pub fn derive_secp256k1(seed: &[u8], path: &DerivationPath) -> k256::SecretKey { .expect("SLIP-0010 secp256k1 derivation produced a valid key") } -pub fn derive_p256(seed: &[u8], path: &DerivationPath) -> p256::SecretKey { +pub(super) fn derive_p256(seed: &[u8], path: &DerivationPath) -> p256::SecretKey { let key_bytes = slip10_derive( seed, path, @@ -40,7 +40,7 @@ pub fn derive_p256(seed: &[u8], path: &DerivationPath) -> p256::SecretKey { } /// Panics if any path component is non-hardened; SLIP-0010 forbids it for Ed25519. -pub fn derive_ed25519(seed: &[u8], path: &DerivationPath) -> ic_ed25519::PrivateKey { +pub(super) fn derive_ed25519(seed: &[u8], path: &DerivationPath) -> ic_ed25519::PrivateKey { let key_bytes = slip10_derive(seed, path, ED25519_SEED_KEY, None, |_| unreachable!()); ic_ed25519::PrivateKey::deserialize_raw(&*key_bytes) .expect("SLIP-0010 ed25519 derivation produced a valid key") From 813178cc678f17d23119436da816aeededf257e2 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Thu, 16 Apr 2026 20:28:33 -0400 Subject: [PATCH 6/6] Bump thin-vec from 0.2.14 to 0.2.16 Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 33e23d3f..70fabd5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6813,9 +6813,9 @@ dependencies = [ [[package]] name = "thin-vec" -version = "0.2.14" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144f754d318415ac792f9d69fc87abbbfc043ce2ef041c60f16ad828f638717d" +checksum = "259cdf8ed4e4aca6f1e9d011e10bd53f524a2d0637d7b28450f6c64ac298c4c6" [[package]] name = "thiserror"