From 6c1172efc32b1ab1f36a062ae322f74eb4eef268 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Wed, 19 Aug 2026 11:29:05 +0200 Subject: [PATCH] v0.2.5-pre.004 --- Cargo.toml | 7 +- ROADMAP.md | 4 +- crates/ksp-wallet-lib/Cargo.toml | 5 +- crates/ksp-wallet-lib/src/crypto.rs | 199 +++++++++++ crates/ksp-wallet-lib/src/error.rs | 4 +- crates/ksp-wallet-lib/src/lib.rs | 14 +- crates/ksp-wallet-lib/src/wire.rs | 8 +- .../tests/dependency_boundary.rs | 5 +- .../fixtures/kspwallet_v1_crypto_vectors.json | 16 + crates/ksp-wallet-lib/tests/public_api.rs | 5 +- crates/ksp-wallet-lib/unit_tests/crypto.rs | 175 +++++++++ crates/ksp-wallet-lib/unit_tests/wire.rs | 18 +- deltas/0.2.5/pre.004.md | 332 ++++++++++++++++++ docs/formats/000-README.md | 4 +- docs/formats/KSPWALLET_V1.md | 78 +++- docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md | 4 +- .../012-V0_2_5_WALLET_FOUNDATION_PLAN.md | 8 +- 17 files changed, 859 insertions(+), 27 deletions(-) create mode 100644 crates/ksp-wallet-lib/src/crypto.rs create mode 100644 crates/ksp-wallet-lib/tests/fixtures/kspwallet_v1_crypto_vectors.json create mode 100644 crates/ksp-wallet-lib/unit_tests/crypto.rs create mode 100644 deltas/0.2.5/pre.004.md diff --git a/Cargo.toml b/Cargo.toml index 8ed1f8e..7f66eb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 146 +# version: 148 [workspace] resolver = "3" members = ["crates/ksp-app-config-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"] [workspace.package] -version = "0.2.5-pre.3.fix.1" +version = "0.2.5-pre.4" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" @@ -14,6 +14,9 @@ authors = ["SinuS von SifriduS "] publish = false [workspace.dependencies] +argon2 = { version = "^0.5", default-features = false } +chacha20poly1305 = { version = "^0.11", default-features = false } +getrandom = { version = "^0.4", default-features = false } base64 = { version = "^0.23" } fs2 = { version = "^0.4" } serde = { version = "^1.0" } diff --git a/ROADMAP.md b/ROADMAP.md index 16da642..1b5b67c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,5 @@ - + # Roadmap KSP @@ -49,7 +49,7 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U - [X] `0.2.2` — HTTP Accounts + Tokens + Cluster : 22 wrappers typés (5 Accounts + 5 Tokens + 12 Cluster), canaries de complétude 52+14, smoke Devnet Transport pur et smoke historique Config -> Transport validés, documentation durable et prompt `0.2.3` publiés stables. - [X] `0.2.3` — HTTP Transactions stable : 11/11 wrappers typés publiés, classification `8 Read / 2 WriteSubmission / 1 Simulation`, no-resend ambigu prouvé pour les write submissions, `KSP-TRANSPORT-007` réaudité conforme sur les 37 wrappers HTTP courants, graphes Cargo et deux smokes Devnet validés ; `0.2.4` reprend les 15 Blocks/Economics restants. - [X] `0.2.4` — HTTP Blocks + Economics stable : 15/15 wrappers `V0_2_4` publiés, surface typed complète à 52/52 méthodes courantes, 14/14 historiques conservées, réaudit SIMD/inventaire final et `KSP-TRANSPORT-007` global validés ; deux smokes Devnet passés avant publication. -- [/] `0.2.5` — Wallet foundation : `pre.001` fixe le threat model et le format V1 autonome ; `pre.002` crée `ksp-wallet-lib` avec capabilities VIEW/OWNER et frontières ; `pre.003` fige l’enveloppe JSON UTF-8 stricte, Base64url canonique, key slots OWNER/VIEW, limites hostiles, transcript OWNER/AAD et la spécification externe initiale `docs/formats/KSPWALLET_V1.md`. `Pubkey` reste consommée uniquement via `ksp-core-lib`; Config/Transport/ExecutionPolicy/Store/Tauri restent hors Wallet. La cryptographie effective, persistence, signature, rotations et import/export restent réparties jusqu’à `pre.010` ; `WalletPolicy` reste exclu. +- [/] `0.2.5` — Wallet foundation : `pre.001` fixe le threat model et le format V1 autonome ; `pre.002` crée `ksp-wallet-lib` avec capabilities VIEW/OWNER et frontières ; `pre.003` fige l’enveloppe JSON stricte, transcript OWNER/AAD et la spécification externe initiale ; `pre.004` ajoute les primitives in-memory Argon2id v19 + XChaCha20-Poly1305 + CSPRNG OS, le wrapping de content keys et un vecteur crypto interopérable. Le choix du profil Argon2 de création reste bloqué sur le benchmark opérateur explicite livré par `pre.004`. `Pubkey` reste consommée uniquement via `ksp-core-lib`; Config/Transport/ExecutionPolicy/Store/Tauri restent hors Wallet. Persistence, state signature, signature Solana, rotations et import/export restent réparties jusqu’à `pre.010` ; `WalletPolicy` reste exclu. - [ ] `0.2.6` — Introduire `ksp-app-wallet-desk` utilisant Config composite + Wallet + transport HTTP, notamment pour afficher l'identité et le solde d'un wallet. - [ ] `0.2.7` — Étendre `ksp-onchain-transport-lib` au WebSocket Solana standard complet ; permettre plusieurs sessions sur une même URL sans imposer encore un pool automatique complexe. - [ ] `0.2.8` — Ajouter Helius LaserStream WebSocket comme extension du moteur WebSocket standard, sans duplication de client. diff --git a/crates/ksp-wallet-lib/Cargo.toml b/crates/ksp-wallet-lib/Cargo.toml index d6ecae9..9daf94b 100644 --- a/crates/ksp-wallet-lib/Cargo.toml +++ b/crates/ksp-wallet-lib/Cargo.toml @@ -1,5 +1,5 @@ # file: crates/ksp-wallet-lib/Cargo.toml -# version: 2 +# version: 3 [package] name = "ksp-wallet-lib" @@ -8,6 +8,9 @@ edition.workspace = true repository.workspace = true [dependencies] +argon2 = { workspace = true, features = ["zeroize"] } +chacha20poly1305 = { workspace = true, features = ["alloc", "zeroize"] } +getrandom.workspace = true ksp-core-lib = { path = "../ksp-core-lib" } ksp-logging-lib = { path = "../ksp-logging-lib" } base64.workspace = true diff --git a/crates/ksp-wallet-lib/src/crypto.rs b/crates/ksp-wallet-lib/src/crypto.rs new file mode 100644 index 0000000..0071872 --- /dev/null +++ b/crates/ksp-wallet-lib/src/crypto.rs @@ -0,0 +1,199 @@ +// file: crates/ksp-wallet-lib/src/crypto.rs +// version: 1 + +//! In-memory cryptographic primitives for native `.kspwallet` V1. +//! +//! `pre.004` intentionally lands these crate-private primitives one tranche before `pre.005` wires them into create/open capability flows. + +#![allow(dead_code, reason = "pre.004 stages private Wallet crypto primitives before their pre.005 production callers")] + +use chacha20poly1305::KeyInit as _; +use chacha20poly1305::aead::Aead as _; + +/// Exact V1 content-key and password-derived-key size in bytes. +const SECRET_KEY_BYTES: usize = 32; + +/// Owned 32-byte secret key with redacted diagnostics and drop-time zeroization. +struct SecretKeyV1 { + bytes: [u8; SECRET_KEY_BYTES], +} + +impl SecretKeyV1 { + /// Takes ownership of exact 32-byte secret material. + const fn from_bytes(bytes: [u8; SECRET_KEY_BYTES]) -> Self { + return Self { bytes }; + } + + /// Generates a fresh secret key from the operating-system CSPRNG. + fn random() -> ksp_core_lib::Result { + let mut bytes = [0_u8; SECRET_KEY_BYTES]; + let fill_result = getrandom::fill(bytes.as_mut_slice()); + if fill_result.is_err() { + zeroize::Zeroize::zeroize(&mut bytes); + return std::result::Result::Err(randomness_error()); + } + return std::result::Result::Ok(Self { bytes }); + } + + /// Borrows secret bytes internally without allocating or cloning. + const fn as_bytes(&self) -> &[u8; SECRET_KEY_BYTES] { + return &self.bytes; + } +} + +impl std::fmt::Debug for SecretKeyV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter.write_str("SecretKeyV1()"); + } +} + +impl std::ops::Drop for SecretKeyV1 { + fn drop(&mut self) { + zeroize::Zeroize::zeroize(&mut self.bytes); + } +} + +/// Generates a fresh XChaCha20-Poly1305 nonce from the operating-system CSPRNG. +fn random_nonce() -> ksp_core_lib::Result<[u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES]> { + let mut nonce = [0_u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES]; + let fill_result = getrandom::fill(nonce.as_mut_slice()); + if fill_result.is_err() { + zeroize::Zeroize::zeroize(&mut nonce); + return std::result::Result::Err(randomness_error()); + } + return std::result::Result::Ok(nonce); +} + +/// Derives one V1 password wrapping key from serialized Argon2id parameters. +fn derive_password_key(password: &[u8], kdf: &crate::WalletKdfParametersV1) -> ksp_core_lib::Result { + return derive_argon2id(password, kdf.salt(), kdf.memory_kib(), kdf.iterations(), kdf.parallelism()); +} + +/// Wraps one 32-byte content key with XChaCha20-Poly1305 and caller-provided domain-separated AAD. +fn wrap_key( + wrapping_key: &SecretKeyV1, + key_to_wrap: &SecretKeyV1, + nonce: &[u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES], + aad: &[u8], +) -> ksp_core_lib::Result> { + return encrypt_bytes(wrapping_key, nonce, aad, key_to_wrap.as_bytes()); +} + +/// Unwraps one 32-byte content key and maps every AEAD authentication failure to the generic Wallet authentication error. +fn unwrap_key( + wrapping_key: &SecretKeyV1, + nonce: &[u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES], + aad: &[u8], + ciphertext: &[u8], +) -> ksp_core_lib::Result { + let plaintext_result = decrypt_bytes(wrapping_key, nonce, aad, ciphertext); + let mut plaintext = match plaintext_result { + std::result::Result::Ok(plaintext) => plaintext, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if plaintext.len() != SECRET_KEY_BYTES { + zeroize::Zeroize::zeroize(plaintext.as_mut_slice()); + return std::result::Result::Err(authentication_error()); + } + let converted = <[u8; SECRET_KEY_BYTES]>::try_from(plaintext.as_slice()); + let key = match converted { + std::result::Result::Ok(key) => SecretKeyV1::from_bytes(key), + std::result::Result::Err(_) => { + zeroize::Zeroize::zeroize(plaintext.as_mut_slice()); + return std::result::Result::Err(authentication_error()); + }, + }; + zeroize::Zeroize::zeroize(plaintext.as_mut_slice()); + return std::result::Result::Ok(key); +} + +/// Encrypts bounded plaintext bytes with XChaCha20-Poly1305 and caller-provided domain-separated AAD. +fn encrypt_bytes( + key: &SecretKeyV1, + nonce: &[u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES], + aad: &[u8], + plaintext: &[u8], +) -> ksp_core_lib::Result> { + let cipher_result = chacha20poly1305::XChaCha20Poly1305::new_from_slice(key.as_bytes()); + let cipher = match cipher_result { + std::result::Result::Ok(cipher) => cipher, + std::result::Result::Err(_) => return std::result::Result::Err(crypto_parameter_error()), + }; + let nonce_value = chacha20poly1305::XNonce::from(*nonce); + let payload = chacha20poly1305::aead::Payload { msg: plaintext, aad }; + let encrypted = cipher.encrypt(&nonce_value, payload); + return match encrypted { + std::result::Result::Ok(ciphertext) => std::result::Result::Ok(ciphertext), + std::result::Result::Err(_) => std::result::Result::Err(crypto_parameter_error()), + }; +} + +/// Decrypts authenticated ciphertext bytes and returns a generic authentication error on tag failure. +fn decrypt_bytes( + key: &SecretKeyV1, + nonce: &[u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES], + aad: &[u8], + ciphertext: &[u8], +) -> ksp_core_lib::Result> { + let cipher_result = chacha20poly1305::XChaCha20Poly1305::new_from_slice(key.as_bytes()); + let cipher = match cipher_result { + std::result::Result::Ok(cipher) => cipher, + std::result::Result::Err(_) => return std::result::Result::Err(crypto_parameter_error()), + }; + let nonce_value = chacha20poly1305::XNonce::from(*nonce); + let payload = chacha20poly1305::aead::Payload { msg: ciphertext, aad }; + let decrypted = cipher.decrypt(&nonce_value, payload); + return match decrypted { + std::result::Result::Ok(plaintext) => std::result::Result::Ok(plaintext), + std::result::Result::Err(_) => std::result::Result::Err(authentication_error()), + }; +} + +fn derive_argon2id(password: &[u8], salt: &[u8], memory_kib: u32, iterations: u32, parallelism: u32) -> ksp_core_lib::Result { + if password.is_empty() || password.len() > crate::KSPWALLET_V1_MAX_PASSWORD_BYTES { + return std::result::Result::Err(crypto_parameter_error()); + } + if salt.len() < crate::KSPWALLET_V1_MIN_KDF_SALT_BYTES || salt.len() > crate::KSPWALLET_V1_MAX_KDF_SALT_BYTES { + return std::result::Result::Err(crypto_parameter_error()); + } + if memory_kib == 0 + || memory_kib > crate::KSPWALLET_V1_MAX_ARGON2_MEMORY_KIB + || iterations == 0 + || iterations > crate::KSPWALLET_V1_MAX_ARGON2_ITERATIONS + || parallelism == 0 + || parallelism > crate::KSPWALLET_V1_MAX_ARGON2_PARALLELISM + || memory_kib < parallelism * 8 + { + return std::result::Result::Err(crypto_parameter_error()); + } + + let params_result = argon2::Params::new(memory_kib, iterations, parallelism, std::option::Option::Some(SECRET_KEY_BYTES)); + let params = match params_result { + std::result::Result::Ok(params) => params, + std::result::Result::Err(_) => return std::result::Result::Err(crypto_parameter_error()), + }; + let argon2 = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); + let mut output = [0_u8; SECRET_KEY_BYTES]; + let derive_result = argon2.hash_password_into(password, salt, output.as_mut_slice()); + if derive_result.is_err() { + zeroize::Zeroize::zeroize(&mut output); + return std::result::Result::Err(crypto_parameter_error()); + } + return std::result::Result::Ok(SecretKeyV1::from_bytes(output)); +} + +fn randomness_error() -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_RANDOMNESS_FAILED, "Wallet cryptographic randomness could not be obtained"); +} + +fn crypto_parameter_error() -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_CRYPTO_PARAMETERS_INVALID, "Wallet cryptographic parameters are invalid or unsupported"); +} + +fn authentication_error() -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_AUTHENTICATION_FAILED, "Wallet cryptographic authentication failed"); +} + +#[cfg(test)] +#[path = "../unit_tests/crypto.rs"] +mod tests; diff --git a/crates/ksp-wallet-lib/src/error.rs b/crates/ksp-wallet-lib/src/error.rs index cb76ec1..2f79ccb 100644 --- a/crates/ksp-wallet-lib/src/error.rs +++ b/crates/ksp-wallet-lib/src/error.rs @@ -1,5 +1,5 @@ // file: crates/ksp-wallet-lib/src/error.rs -// version: 1 +// version: 2 /// Error code used when a native Wallet structure is invalid. pub const ERROR_CODE_FORMAT_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet", "format_invalid"); @@ -7,6 +7,8 @@ pub const ERROR_CODE_FORMAT_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::Err pub const ERROR_CODE_FORMAT_VERSION_UNSUPPORTED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet", "format_version_unsupported"); /// Error code used when serialized cryptographic parameters are invalid or unsupported. pub const ERROR_CODE_CRYPTO_PARAMETERS_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet", "crypto_parameters_invalid"); +/// Error code used when the operating-system cryptographic random source cannot provide bytes. +pub const ERROR_CODE_RANDOMNESS_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet", "randomness_failed"); /// Error code used when an authenticated Wallet structure cannot be verified. pub const ERROR_CODE_AUTHENTICATION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet", "authentication_failed"); /// Error code used when a VIEW unlock attempt fails without exposing a finer cryptographic oracle. diff --git a/crates/ksp-wallet-lib/src/lib.rs b/crates/ksp-wallet-lib/src/lib.rs index 62a89a6..0103b65 100644 --- a/crates/ksp-wallet-lib/src/lib.rs +++ b/crates/ksp-wallet-lib/src/lib.rs @@ -1,5 +1,5 @@ // file: crates/ksp-wallet-lib/src/lib.rs -// version: 3 +// version: 4 #![warn(missing_docs)] #![deny(unreachable_pub)] #![forbid(unsafe_code)] @@ -7,13 +7,15 @@ //! Autonomous KSP Wallet foundation. //! //! `ksp-wallet-lib` owns the native `.kspwallet` domain, VIEW/OWNER capability model, protected metadata projection, password-secret wrappers and Wallet -//! error contract. `0.2.5-pre.003` additionally freezes the strict V1 JSON envelope, canonical Base64url decoding, structural limits and deterministic -//! state-transcript/AEAD-AAD byte codecs. It still performs no KDF, encryption, decryption, state-signature verification, Solana signing or filesystem -//! persistence. Public keys are consumed exclusively through the [`ksp_core_lib::Pubkey`] re-export owned by KSP Core, and behavioral observability uses -//! only `ksp-logging-lib` with the explicit crate target defined in `src/constants.rs`. +//! error contract. `0.2.5-pre.003` freezes the strict V1 JSON envelope, canonical Base64url decoding, structural limits and deterministic +//! state-transcript/AEAD-AAD byte codecs. `0.2.5-pre.004` adds the in-memory Argon2id/XChaCha20-Poly1305/CSPRNG primitives and deterministic crypto +//! vectors used by later create/open operations. It still performs no state-signature verification, Solana signing or filesystem persistence. Public +//! keys are consumed exclusively through the [`ksp_core_lib::Pubkey`] re-export owned by KSP Core, and behavioral observability uses only +//! `ksp-logging-lib` with the explicit crate target defined in `src/constants.rs`. mod capability; mod constants; +mod crypto; mod error; mod metadata; mod owner; @@ -106,6 +108,8 @@ pub use self::error::ERROR_CODE_IO_FAILED; pub use self::error::ERROR_CODE_KEY_MATERIAL_INVALID; /// Error code used when an OWNER unlock attempt fails without exposing a finer cryptographic oracle. pub use self::error::ERROR_CODE_OWNER_UNLOCK_FAILED; +/// Error code used when the operating-system cryptographic random source cannot provide bytes. +pub use self::error::ERROR_CODE_RANDOMNESS_FAILED; /// Error code used when a Wallet signing operation fails. pub use self::error::ERROR_CODE_SIGNATURE_FAILED; /// Error code used when an import/export transfer format is unsupported. diff --git a/crates/ksp-wallet-lib/src/wire.rs b/crates/ksp-wallet-lib/src/wire.rs index 9dd6aba..5744a28 100644 --- a/crates/ksp-wallet-lib/src/wire.rs +++ b/crates/ksp-wallet-lib/src/wire.rs @@ -1,5 +1,5 @@ // file: crates/ksp-wallet-lib/src/wire.rs -// version: 1 +// version: 3 //! Strict native `.kspwallet` V1 wire envelope. @@ -893,6 +893,12 @@ fn parse_kdf(raw: RawKdfV1) -> ksp_core_lib::Result { if raw.parallelism == 0 || raw.parallelism > crate::KSPWALLET_V1_MAX_ARGON2_PARALLELISM { return std::result::Result::Err(crypto_parameter_error("Wallet Argon2 parallelism is outside the V1 structural range", "key_slots.kdf.parallelism")); } + if raw.memory_kib < raw.parallelism * 8 { + return std::result::Result::Err(crypto_parameter_error( + "Wallet Argon2 memory_kib must provide at least eight 1-KiB blocks per lane", + "key_slots.kdf.memory_kib", + )); + } let salt = match decode_base64url(raw.salt.as_str(), "key_slots.kdf.salt") { std::result::Result::Ok(salt) => salt, std::result::Result::Err(error) => return std::result::Result::Err(error), diff --git a/crates/ksp-wallet-lib/tests/dependency_boundary.rs b/crates/ksp-wallet-lib/tests/dependency_boundary.rs index 95771e9..b136142 100644 --- a/crates/ksp-wallet-lib/tests/dependency_boundary.rs +++ b/crates/ksp-wallet-lib/tests/dependency_boundary.rs @@ -1,5 +1,5 @@ // file: crates/ksp-wallet-lib/tests/dependency_boundary.rs -// version: 3 +// version: 4 //! Wallet-specific dependency and ownership canaries. @@ -40,6 +40,9 @@ fn wallet_manifest_preserves_dependency_firewall() -> std::io::Result<()> { }; assert!(manifest.contains("ksp-core-lib")); assert!(manifest.contains("ksp-logging-lib")); + assert!(manifest.contains("argon2 = { workspace = true, features = [\"zeroize\"] }")); + assert!(manifest.contains("chacha20poly1305 = { workspace = true, features = [\"alloc\", \"zeroize\"] }")); + assert!(manifest.contains("getrandom.workspace = true")); assert!(manifest.contains("base64.workspace = true")); assert!(manifest.contains("serde = { workspace = true, features = [\"derive\"] }")); assert!(manifest.contains("serde_json.workspace = true")); diff --git a/crates/ksp-wallet-lib/tests/fixtures/kspwallet_v1_crypto_vectors.json b/crates/ksp-wallet-lib/tests/fixtures/kspwallet_v1_crypto_vectors.json new file mode 100644 index 0000000..08632f4 --- /dev/null +++ b/crates/ksp-wallet-lib/tests/fixtures/kspwallet_v1_crypto_vectors.json @@ -0,0 +1,16 @@ +{ + "vector_version": 1, + "warning": "TEST ONLY. These passwords and keys are public fixtures and MUST NOT be used for a real wallet.", + "argon2id_xchacha20poly1305_wrap": { + "password_utf8": "kspwallet-pre004-vector-owner", + "salt": "AAECAwQFBgcICQoLDA0ODw", + "memory_kib": 32, + "iterations": 2, + "parallelism": 1, + "derived_key": "zEky0GdSA4qbfHhMWRHXUcTTGj-nRkMcblqitBW9miU", + "content_key": "oKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr8", + "nonce": "QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZX", + "aad": "S1NQV0FMTEVULVYxLVBSRTAwNC1XUkFQAA", + "wrapped_key": "8SgqlNTyE033pmdv02owxOI3IFgxl0fwu-2ud609ngW_hzmXrrxRldY5YqJ5NW90" + } +} diff --git a/crates/ksp-wallet-lib/tests/public_api.rs b/crates/ksp-wallet-lib/tests/public_api.rs index 158594c..67d7b35 100644 --- a/crates/ksp-wallet-lib/tests/public_api.rs +++ b/crates/ksp-wallet-lib/tests/public_api.rs @@ -1,5 +1,5 @@ // file: crates/ksp-wallet-lib/tests/public_api.rs -// version: 2 +// version: 3 //! Public API canaries for the Wallet foundation. @@ -43,6 +43,7 @@ fn wallet_error_codes_are_available_from_crate_root() { ksp_wallet_lib::ERROR_CODE_FORMAT_INVALID, ksp_wallet_lib::ERROR_CODE_FORMAT_VERSION_UNSUPPORTED, ksp_wallet_lib::ERROR_CODE_CRYPTO_PARAMETERS_INVALID, + ksp_wallet_lib::ERROR_CODE_RANDOMNESS_FAILED, ksp_wallet_lib::ERROR_CODE_AUTHENTICATION_FAILED, ksp_wallet_lib::ERROR_CODE_VIEW_UNLOCK_FAILED, ksp_wallet_lib::ERROR_CODE_OWNER_UNLOCK_FAILED, @@ -54,7 +55,7 @@ fn wallet_error_codes_are_available_from_crate_root() { ksp_wallet_lib::ERROR_CODE_KEY_MATERIAL_INVALID, ksp_wallet_lib::ERROR_CODE_SIGNATURE_FAILED, ]; - assert_eq!(codes.len(), 13); + assert_eq!(codes.len(), 14); for code in codes { assert_eq!(code.domain(), "wallet"); } diff --git a/crates/ksp-wallet-lib/unit_tests/crypto.rs b/crates/ksp-wallet-lib/unit_tests/crypto.rs new file mode 100644 index 0000000..a59fe98 --- /dev/null +++ b/crates/ksp-wallet-lib/unit_tests/crypto.rs @@ -0,0 +1,175 @@ +// file: crates/ksp-wallet-lib/unit_tests/crypto.rs +// version: 1 + +use base64::Engine as _; + +const VECTOR: &[u8] = include_bytes!("../tests/fixtures/kspwallet_v1_crypto_vectors.json"); + +#[derive(serde::Deserialize)] +struct CryptoVector { + password_utf8: std::string::String, + salt: std::string::String, + memory_kib: u32, + iterations: u32, + parallelism: u32, + derived_key: std::string::String, + content_key: std::string::String, + nonce: std::string::String, + aad: std::string::String, + wrapped_key: std::string::String, +} + +#[derive(serde::Deserialize)] +struct CryptoVectors { + vector_version: u32, + warning: std::string::String, + argon2id_xchacha20poly1305_wrap: CryptoVector, +} + +#[test] +fn deterministic_argon2id_and_xchacha_wrap_vector_matches_external_canary() -> ksp_core_lib::Result<()> { + let vectors_result = serde_json::from_slice::(VECTOR); + let vectors = match vectors_result { + std::result::Result::Ok(vectors) => vectors, + std::result::Result::Err(_) => { + return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_FORMAT_INVALID, "Wallet crypto test vector JSON is invalid")); + }, + }; + assert_eq!(vectors.vector_version, 1); + assert!(vectors.warning.contains("TEST ONLY")); + let vector = vectors.argon2id_xchacha20poly1305_wrap; + let salt = match decode(vector.salt.as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let expected_derived = match decode_fixed::<32>(vector.derived_key.as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let content_key = match decode_fixed::<32>(vector.content_key.as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let nonce = match decode_fixed::<{ crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES }>(vector.nonce.as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let aad = match decode(vector.aad.as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let expected_wrapped = match decode(vector.wrapped_key.as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + + let derived_result = super::derive_argon2id(vector.password_utf8.as_bytes(), salt.as_slice(), vector.memory_kib, vector.iterations, vector.parallelism); + let derived = match derived_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(derived.as_bytes(), &expected_derived); + + let content = super::SecretKeyV1::from_bytes(content_key); + let wrapped_result = super::wrap_key(&derived, &content, &nonce, aad.as_slice()); + let wrapped = match wrapped_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(wrapped, expected_wrapped); + + let unwrapped_result = super::unwrap_key(&derived, &nonce, aad.as_slice(), wrapped.as_slice()); + let unwrapped = match unwrapped_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(unwrapped.as_bytes(), content.as_bytes()); + return std::result::Result::Ok(()); +} + +#[test] +fn xchacha_tampering_is_reported_as_generic_authentication_failure() -> ksp_core_lib::Result<()> { + let key = super::SecretKeyV1::from_bytes([0x11_u8; 32]); + let nonce = [0x22_u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES]; + let plaintext = [0x33_u8; 32]; + let encrypted_result = super::encrypt_bytes(&key, &nonce, b"kspwallet-test-aad", plaintext.as_slice()); + let mut encrypted = match encrypted_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + encrypted[0] ^= 1; + let result = super::decrypt_bytes(&key, &nonce, b"kspwallet-test-aad", encrypted.as_slice()); + assert!(result.is_err()); + let error = match result { + std::result::Result::Err(error) => error, + std::result::Result::Ok(_) => return std::result::Result::Ok(()), + }; + assert_eq!(error.code(), crate::ERROR_CODE_AUTHENTICATION_FAILED); + return std::result::Result::Ok(()); +} + +#[test] +fn secret_key_debug_is_redacted_and_random_sources_are_callable() -> ksp_core_lib::Result<()> { + let key_result = super::SecretKeyV1::random(); + let key = match key_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let nonce_result = super::random_nonce(); + let nonce = match nonce_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let rendered = format!("{key:?}"); + assert_eq!(rendered, "SecretKeyV1()"); + assert_eq!(nonce.len(), crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES); + return std::result::Result::Ok(()); +} + +#[test] +#[ignore = "operator-only Argon2 calibration; run single-threaded with --nocapture"] +fn benchmark_argon2_creation_candidates() -> ksp_core_lib::Result<()> { + let password = b"KSPWALLET-BENCHMARK-TEST-ONLY-PASSWORD"; + let salt = [0x5a_u8; 16]; + let candidates = [(65_536_u32, 3_u32, 1_u32), (131_072_u32, 3_u32, 1_u32), (262_144_u32, 3_u32, 1_u32)]; + for candidate in candidates { + let started = std::time::Instant::now(); + let result = super::derive_argon2id(password, salt.as_slice(), candidate.0, candidate.1, candidate.2); + match result { + std::result::Result::Ok(_) => {}, + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + println!( + "KSP Argon2id candidate memory_kib={} iterations={} parallelism={} elapsed_ms={}", + candidate.0, + candidate.1, + candidate.2, + started.elapsed().as_millis() + ); + } + return std::result::Result::Ok(()); +} + +fn decode(value: &str) -> ksp_core_lib::Result> { + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(value.as_bytes()); + return match decoded { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(_) => { + std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_FORMAT_INVALID, "Wallet crypto test vector Base64url is invalid")) + }, + }; +} + +fn decode_fixed(value: &str) -> ksp_core_lib::Result<[u8; LENGTH]> { + let decoded = match decode(value) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let converted = <[u8; LENGTH]>::try_from(decoded.as_slice()); + return match converted { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(_) => { + std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_FORMAT_INVALID, "Wallet crypto test vector has an invalid fixed-size field")) + }, + }; +} diff --git a/crates/ksp-wallet-lib/unit_tests/wire.rs b/crates/ksp-wallet-lib/unit_tests/wire.rs index 1cc1201..6fc508a 100644 --- a/crates/ksp-wallet-lib/unit_tests/wire.rs +++ b/crates/ksp-wallet-lib/unit_tests/wire.rs @@ -1,5 +1,5 @@ // file: crates/ksp-wallet-lib/unit_tests/wire.rs -// version: 1 +// version: 2 const FIXTURE: &[u8] = include_bytes!("../tests/fixtures/kspwallet_v1_wire_only.json"); @@ -100,6 +100,22 @@ fn zero_or_pathological_kdf_parameters_are_rejected_before_crypto() { assert_eq!(high_error.code(), crate::ERROR_CODE_CRYPTO_PARAMETERS_INVALID); } +#[test] +fn argon2_memory_must_cover_all_lanes_before_crypto() { + let source = std::string::String::from_utf8_lossy(FIXTURE).replacen("\"memory_kib\": 65536", "\"memory_kib\": 8", 1).replacen( + "\"parallelism\": 1", + "\"parallelism\": 2", + 1, + ); + let result = super::KspWalletEnvelopeV1::parse_json(source.as_bytes()); + assert!(result.is_err()); + let error = match result { + std::result::Result::Err(error) => error, + std::result::Result::Ok(_) => return, + }; + assert_eq!(error.code(), crate::ERROR_CODE_CRYPTO_PARAMETERS_INVALID); +} + #[test] fn oversized_document_is_rejected_before_json_or_crypto() { let oversized = std::vec![b' '; crate::KSPWALLET_MAX_FILE_BYTES + 1]; diff --git a/deltas/0.2.5/pre.004.md b/deltas/0.2.5/pre.004.md new file mode 100644 index 0000000..9ab0b5c --- /dev/null +++ b/deltas/0.2.5/pre.004.md @@ -0,0 +1,332 @@ + + + +# Delta `0.2.5-pre.004` — Argon2id + XChaCha20-Poly1305 + CSPRNG + vecteur crypto + +## Base requise + +```text +livraison : 0.2.5-pre.003-fix.001 +workspace.package.version = "0.2.5-pre.3.fix.1" +``` + +La base effective est `pre.003` complétée par `pre.003-fix.001` et par les deux réorganisations opérateur conservées dans `constants.rs` et `lib.rs` : bloc unique de `pub const` avant `pub(crate) const`, puis groupe unique de `pub use` avant le groupe `pub(crate) use`. + +Les validations opérateur fournies pour cette base sont propres : + +```text +cargo fmt --all -> terminé +cargo check --workspace -> terminé sans warning +cargo clippy --workspace --all-targets -> terminé sans warning +cargo test -p ksp-wallet-lib -> 18 unit tests OK + 2 dependency-boundary tests OK + 5 public API tests OK + 2 doc-tests compile_fail OK + 0 échec +cargo tree -p ksp-wallet-lib -d -> aucune duplication dans le sous-arbre Wallet +``` + +Le workspace possède par ailleurs plusieurs générations transitives de `base64` (`0.21.7`, `0.22.1`, `0.23.1`), mais le sous-arbre `ksp-wallet-lib` validé en `pre.003-fix.001` ne contient aucune duplication. Les audits inverses doivent donc qualifier la version (`base64@0.23.1`) lorsque nécessaire. + +## Objectif + +Matérialiser la première couche cryptographique **in-memory** du format V1 sans encore créer/ouvrir un wallet complet : + +```text +Argon2id v19 password KDF +KEK 32 octets +XChaCha20-Poly1305 +content keys 32 octets +wrapping / unwrapping authentifié +CSPRNG OS via getrandom +zeroization des clés/buffers secrets possédés +premier vecteur public KDF + wrapping +benchmark opérateur des candidats Argon2 de création +``` + +Restent explicitement hors tranche : + +```text +payload owner-control réel +payload metadata réel +payload secret réel +state signature Ed25519 effective +create/open VIEW/OWNER +filesystem/persistence +signature Solana +rotations de passwords +import/export +``` + +## Version Cargo + +Conformément à `VER-ID-009` : + +```text +0.2.5-pre.3.fix.1 -> 0.2.5-pre.4 +``` + +Le compteur documentaire du `Cargo.toml` racine passe de `146` à `148` : `pre.003-fix.001` avait modifié ce fichier sans consommer le numéro `147`; cette livraison restaure l'historique `GEN-FILE-004` avant sa propre modification. Le même principe est appliqué à `wire.rs` (`1 -> 3`, avec `2` consommé conceptuellement par `pre.003-fix.001`) et à `lib.rs` (`2 -> 4`, avec `3` correspondant à la réorganisation opérateur post-`pre.003`). + +## Dépendances + +Nouvelles dépendances communes sous `[workspace.dependencies]` : + +```toml +argon2 = { version = "^0.5", default-features = false } +chacha20poly1305 = { version = "^0.11", default-features = false } +getrandom = { version = "^0.4", default-features = false } +``` + +Le manifest Wallet active uniquement les features consommées : + +```toml +argon2 = { workspace = true, features = ["zeroize"] } +chacha20poly1305 = { workspace = true, features = ["alloc", "zeroize"] } +getrandom.workspace = true +``` + +Audit actuel de la tranche : + +```text +argon2 génération stable 0.5.x, publiée 0.5.3 ; 0.6 reste release-candidate +chacha20poly1305 génération stable 0.11.x, publiée 0.11.0 +getrandom génération stable 0.4.x, publiée 0.4.3 +zeroize déjà présent au workspace +``` + +`argon2` n'active ni API PHC/password-hash, ni rand, ni alloc par commodité ; Wallet utilise directement `hash_password_into`. `chacha20poly1305` active `alloc` parce que l'API AEAD utilisée retourne les ciphertexts/plaintexts dans des `Vec`, et `zeroize` afin de durcir les états internes de la primitive. Le CSPRNG est possédé explicitement par Wallet via `getrandom` plutôt que par une feature de génération aléatoire de l'AEAD. + +Aucun client RPC, aucune crate Solana supplémentaire, Config, Transport, Store, Tauri ou `tracing` direct n'est ajouté. + +## Argon2id V1 + +La dérivation utilise exactement : + +```text +algorithm Argon2id +version 19 / 0x13 +password octets UTF-8 exacts fournis par le caller +salt octets sérialisés dans le key slot +m_cost memory_kib sérialisé +t_cost iterations sérialisé +p_cost parallelism sérialisé +output 32 octets +pepper aucun +secret ext. aucun +``` + +Les bornes V1 déjà figées sont réappliquées avant le KDF. La tranche ajoute explicitement l'invariant Argon2 : + +```text +memory_kib >= 8 * parallelism +``` + +Un password vide ou supérieur à `1024` octets est rejeté par la primitive interne. + +Le KDF reste sync/in-memory à ce niveau bas. `pre.005+` doit l'appeler depuis les futurs flux async create/open via la frontière blocking prévue par le plan ; aucune API async fictive n'est ajoutée ici. + +## Default Argon2 non figé + +Cette livraison **ne choisit pas arbitrairement** le profil de création. Le test opérateur ignoré par défaut compare : + +```text +64 MiB / 3 passes / 1 lane +128 MiB / 3 passes / 1 lane +256 MiB / 3 passes / 1 lane +``` + +Commande dédiée après les validations normales : + +```bash +cargo test -p ksp-wallet-lib benchmark_argon2_creation_candidates -- --ignored --nocapture --test-threads=1 +``` + +Le résultat doit être conservé dans le retour opérateur. `pre.005` ne figera le default de création qu'après examen de ces mesures. Les wallets existants resteront indépendants de ce default grâce aux paramètres sérialisés dans chaque slot. + +## XChaCha20-Poly1305 + +La primitive V1 utilise : + +```text +key 32 octets +nonce 24 octets +AAD bytes TLV domain-separated figés en pre.003 +ciphertext = encrypted bytes || tag Poly1305 16 octets +``` + +Le nonce est toujours fourni séparément à l'AEAD. La tranche ne construit aucune canonicalisation JSON crypto : les AAD restent ceux du codec sémantique TLV de `pre.003`. + +Toute erreur de tag/déchiffrement est ramenée au code générique : + +```text +wallet/authentication_failed +``` + +Le code ne produit donc pas d'oracle public distinguant key, nonce, AAD ou ciphertext incorrect. + +## CSPRNG et secrets en mémoire + +`getrandom::fill` fournit directement les octets provenant de la source cryptographique de l'OS. Une défaillance devient : + +```text +wallet/randomness_failed +``` + +Aucun secret, seed ou état RNG KSP n'est introduit. + +Le type interne de clé 32 octets : + +```text +n'est ni Copy ni Clone +Debug => SecretKeyV1() +zeroize ses octets au Drop +``` + +L'unwrap zeroize également son buffer plaintext intermédiaire après transfert dans le type secret possédé. + +Les primitives restent volontairement privées en `pre.004`. Un `allow(dead_code)` **local au seul module `crypto.rs` et documenté** existe parce que cette tranche matérialise ces primitives une prerelease avant leurs callers production `create/open` de `pre.005`; il ne modifie aucun lint workspace et doit disparaître lorsque ces callers sont branchés. + +## Vecteur cryptographique public + +Nouveau fixture : + +```text +crates/ksp-wallet-lib/tests/fixtures/kspwallet_v1_crypto_vectors.json +``` + +Il contient exclusivement des valeurs publiques **TEST ONLY** et fixe de manière reproductible : + +```text +password UTF-8 +salt +m/t/p Argon2id +KEK dérivée attendue +content key 32 octets +nonce XChaCha 24 octets +AAD +wrapped key attendu +``` + +Les paramètres du vecteur (`32 KiB / 2 passes / 1 lane`) sont volontairement faibles pour conserver un test déterministe rapide. Ils ne constituent jamais le default de production ni une recommandation de sécurité. + +Le vecteur a été recalculé indépendamment du code Rust avec une implémentation Argon2id et une construction XChaCha20-Poly1305 `HChaCha20 + ChaCha20-Poly1305 IETF`; l'implémentation indépendante a d'abord été vérifiée contre le vecteur HChaCha20 publié par la spécification XChaCha. + +Les tests vérifient : + +```text +KEK attendue +ciphertext/tag attendu +unwrap exact de la content key +tampering -> authentication_failed +Debug secret redacted +CSPRNG key/nonce callable +``` + +## Wire et spécification + +`wire.rs` rejette maintenant avant crypto un tuple Argon2 dont la mémoire ne satisfait pas les huit blocs de 1 KiB par lane. + +`docs/formats/KSPWALLET_V1.md` documente désormais indépendamment du code Rust : + +```text +Argon2id v19 exact +password UTF-8 sans normalisation implicite +output KEK 32 octets +absence de pepper/secret externe +XChaCha20-Poly1305 key/nonce/tag +CSPRNG OS +content keys 32 octets +wrapping OWNER / VIEW +emplacement du vecteur crypto public +statut explicitement non fixé du default Argon2 de création +``` + +Le format V1 reste autonome : tous les salts, paramètres et nonces nécessaires sont dans le `.kspwallet`; aucun facteur externe n'est introduit. + +## Canaries de dépendances + +`dependency_boundary.rs` vérifie les trois nouvelles dépendances Wallet et leurs features, tout en conservant les interdictions : + +```text +Wallet -X-> Config +Wallet -X-> Transport +Wallet -X-> ExecutionPolicy +Wallet -X-> Store +Wallet -X-> Tauri +Wallet -X-> tracing direct +Wallet -X-> solana-pubkey direct +``` + +`Pubkey` reste possédée/réexportée par `ksp-core-lib`; cette tranche n'ajoute aucune dépendance Solana Wallet. + +## Validation attendue + +Après application : + +```bash +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-wallet-lib +cargo test --workspace +``` + +Audit de dépendances/features de la tranche : + +```bash +cargo tree -p ksp-wallet-lib +cargo tree -p ksp-wallet-lib -d +cargo tree -e features -p ksp-wallet-lib +cargo tree -i argon2@0.5.3 +cargo tree -i chacha20poly1305@0.11.0 +cargo tree -i getrandom@0.4.3 +cargo tree -i base64@0.23.1 +cargo tree -i zeroize@1.9.0 +``` + +L'audit doit confirmer au minimum : + +```text +pas de duplication dans le sous-arbre Wallet sans justification +pas de default features Argon2/ChaCha réactivées accidentellement +pas de Config/Transport/Tauri/RPC Solana transitif propre à Wallet +base64 0.23.1 reste la génération consommée directement par Wallet +``` + +Puis exécuter séparément le benchmark opérateur : + +```bash +cargo test -p ksp-wallet-lib benchmark_argon2_creation_candidates -- --ignored --nocapture --test-threads=1 +``` + +## Fichiers ajoutés + +```text +crates/ksp-wallet-lib/src/crypto.rs +crates/ksp-wallet-lib/unit_tests/crypto.rs +crates/ksp-wallet-lib/tests/fixtures/kspwallet_v1_crypto_vectors.json +deltas/0.2.5/pre.004.md +``` + +## Fichiers modifiés + +```text +Cargo.toml +ROADMAP.md +crates/ksp-wallet-lib/Cargo.toml +crates/ksp-wallet-lib/src/error.rs +crates/ksp-wallet-lib/src/lib.rs +crates/ksp-wallet-lib/src/wire.rs +crates/ksp-wallet-lib/tests/dependency_boundary.rs +crates/ksp-wallet-lib/tests/public_api.rs +crates/ksp-wallet-lib/unit_tests/wire.rs +docs/formats/000-README.md +docs/formats/KSPWALLET_V1.md +docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md +docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md +``` + +## Suite + +`0.2.5-pre.005` consommera ces primitives pour les compartiments `owner_control` / metadata / secret, `create/open VIEW/OWNER` et l'authentification d'état OWNER niveau B. Le default Argon2 de création sera figé uniquement après réception du benchmark opérateur de cette tranche. diff --git a/docs/formats/000-README.md b/docs/formats/000-README.md index 7170c2c..618a8a9 100644 --- a/docs/formats/000-README.md +++ b/docs/formats/000-README.md @@ -1,5 +1,5 @@ - + # Formats KSP @@ -9,4 +9,4 @@ Une spécification de format décrit le wire exact, les encodages, les limites, ## Formats actifs -- [`KSPWALLET_V1.md`](KSPWALLET_V1.md) — spécification du format natif autonome `.kspwallet` V1. `0.2.5-pre.003` fige son enveloppe JSON stricte, ses limites structurelles, ses key slots, son transcript OWNER et ses AAD ; les paramètres de création KDF/crypto effectifs et les vecteurs cryptographiques complets sont consolidés par les prereleases Wallet suivantes. +- [`KSPWALLET_V1.md`](KSPWALLET_V1.md) — spécification du format natif autonome `.kspwallet` V1. `0.2.5-pre.003` fige son enveloppe JSON stricte, ses limites structurelles, ses key slots, son transcript OWNER et ses AAD ; `0.2.5-pre.004` ajoute Argon2id/XChaCha20-Poly1305/CSPRNG OS, le wrapping de content keys et le premier vecteur crypto déterministe. Le default Argon2 de création reste volontairement en attente du benchmark opérateur livré par cette tranche. diff --git a/docs/formats/KSPWALLET_V1.md b/docs/formats/KSPWALLET_V1.md index faf16e7..3d4867f 100644 --- a/docs/formats/KSPWALLET_V1.md +++ b/docs/formats/KSPWALLET_V1.md @@ -1,5 +1,5 @@ - + # `.kspwallet` V1 — spécification du format natif Wallet KSP @@ -20,7 +20,7 @@ AAD des compartiments owner-control / metadata / secret règles unknown-field / unknown-version ``` -Les prereleases suivantes complètent les paramètres de création Argon2id benchmarkés, les opérations cryptographiques, les payloads plaintext exacts et les vecteurs cryptographiques complets. Toute évolution qui modifie un élément déjà déclaré **figé** par cette spécification exige une évolution explicitement tracée avant la release stable ; après publication de V1, une incompatibilité de wire exige un nouveau `format_version`. +`0.2.5-pre.004` complète maintenant les primitives KDF/AEAD normatives et un premier vecteur cryptographique public. Le **default de création Argon2id** reste volontairement non normatif tant que le benchmark opérateur de `pre.004` n'a pas été exécuté. Les prereleases suivantes complètent les payloads plaintext exacts, l'autorité Ed25519, les opérations create/open et les vecteurs de wallet complets. Toute évolution qui modifie un élément déjà déclaré **figé** par cette spécification exige une évolution explicitement tracée avant la release stable ; après publication de V1, une incompatibilité de wire exige un nouveau `format_version`. Le but final est qu'une implémentation indépendante en Rust, Python, Go, C/C++, Java ou autre puisse créer, parser, vérifier et ouvrir un `.kspwallet` sans lire le code source de `ksp-wallet-lib`. @@ -219,6 +219,7 @@ Bornes structurelles de parsing : memory_kib : 1 .. 1 048 576 iterations : 1 .. 64 parallelism : 1 .. 64 +memory_kib >= 8 * parallelism salt : 16 .. 64 octets ``` @@ -602,16 +603,85 @@ AAD exacts round-trip du codec ``` -Les vecteurs cryptographiques publics complets avec passwords et secret test-only connus sont ajoutés après implémentation KDF/AEAD/wrapping/signature. +Le premier vecteur cryptographique public KDF+wrapping est ajouté par `pre.004`. Les vecteurs de wallet complets incluant payloads et state signature sont ajoutés après implémentation des compartiments et de l'autorité OWNER. ## 19. Invariants encore à compléter sans modifier le wire figé Les tranches suivantes doivent compléter : ```text -pre.004 : defaults Argon2 benchmarkés + implémentation KDF/AEAD/wrapping + vecteurs crypto +pre.004 : sélection finale du default Argon2 après benchmark opérateur (KDF/AEAD/wrapping/vecteur déjà implémentés) pre.005 : payloads owner-control/metadata/secret + create/open VIEW/OWNER + state signature effective pre.006+ : persistence/administration/signature/import-export selon le plan Wallet ``` Toute découverte imposant de modifier la grammaire, les tags, l'ordre transcript ou les domain separators définis dans ce document doit être traitée explicitement avant la publication stable, jamais masquée par une tolérance du parseur. + +## 20. Primitives cryptographiques effectives depuis `pre.004` + +### 20.1 Argon2id + +La dérivation d'une key-encryption key (KEK) V1 utilise exactement : + +```text +algorithm = Argon2id +version = 19 / 0x13 +output = 32 octets +password = octets UTF-8 exacts fournis par le caller +salt = octets sérialisés dans le key slot +m_cost = memory_kib sérialisé +t_cost = iterations sérialisé +p_cost = parallelism sérialisé +pepper = aucun +secret Argon2 externe = aucun +``` + +Un password vide est rejeté. V1 limite l'entrée password à 1024 octets UTF-8. Le KDF est une opération CPU/mémoire coûteuse ; les futures API async create/open l'exécuteront hors du thread executor conformément au plan Wallet. + +Le default de création n'est **pas** déterminé par les defaults de la crate RustCrypto. Le benchmark opérateur compare explicitement : + +```text +64 MiB / 3 passes / 1 lane +128 MiB / 3 passes / 1 lane +256 MiB / 3 passes / 1 lane +``` + +Le profil retenu sera enregistré après mesure sur machine cible. Un wallet conserve toujours ses propres paramètres sérialisés, indépendamment des defaults futurs. + +### 20.2 XChaCha20-Poly1305 + +V1 utilise une clé de **32 octets** et un nonce de **24 octets**. Le résultat `ciphertext` stocké est la sortie AEAD avec tag Poly1305 postfixé de 16 octets. Chaque chiffrement/wrapping de production reçoit un nonce neuf fourni par le CSPRNG OS. Les AAD sont les TLV domain-separated définis dans cette spécification ; ils ne sont ni secrets ni chiffrés. + +Une erreur de déchiffrement/tag est exposée comme une erreur générique d'authentification Wallet et ne distingue pas ciphertext, key, nonce ou AAD incorrect. + +### 20.3 CSPRNG + +KSP utilise la source cryptographique du système d'exploitation via `getrandom`. V1 ne définit aucun PRNG, seed ou état aléatoire propriétaire KSP. La génération de content keys, salts et nonces de production doit échouer si le CSPRNG OS échoue. + +### 20.4 Content keys et wrapping + +Les content keys V1 manipulées par les primitives de `pre.004` sont des clés symétriques de 32 octets : + +```text +OWNER password -> Argon2id -> KEK_OWNER -> XChaCha wrap -> K_owner_root +VIEW password -> Argon2id -> KEK_VIEW -> XChaCha wrap -> K_metadata +``` + +Les clés possédées en mémoire sont non-`Copy`, non-`Clone` par défaut, redacted en `Debug` et zeroized au `Drop`. Lors d'un unwrap, le plaintext intermédiaire est zeroized après copie dans le type secret possédé. + +### 20.5 Vecteur interopérable `pre.004` + +Le fichier public : + +```text +crates/ksp-wallet-lib/tests/fixtures/kspwallet_v1_crypto_vectors.json +``` + +contient un password, un salt, une content key et un nonce **test-only publics**. Il fixe : + +```text +Argon2id -> derived_key attendu +XChaCha20-Poly1305(derived_key, nonce, AAD, content_key) -> wrapped_key attendu +``` + +Le vecteur a été recalculé indépendamment de l'implémentation Rust avec Argon2id puis la construction XChaCha20-Poly1305 `HChaCha20 + ChaCha20-Poly1305 IETF`. Ces valeurs ne constituent jamais des secrets de production. diff --git a/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md b/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md index 7a42f33..dfdaca9 100644 --- a/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md +++ b/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md @@ -1,5 +1,5 @@ - + # Séquence des releases fonctionnelles KSP @@ -412,6 +412,8 @@ La release doit fournir `docs/formats/KSPWALLET_V1.md` comme spécification sép `0.2.5-pre.003` ouvre le format sans effectuer encore de cryptographie : `KspWalletEnvelopeV1` parse/serialize le JSON strict borné à 1 MiB, impose Base64url sans padding canonique, exactement un slot OWNER et un slot VIEW optionnel lié par `view_descriptor`, puis produit les octets déterministes du transcript OWNER et des AAD par TLV domain-separated. `docs/formats/KSPWALLET_V1.md` devient l'autorité indépendante du code pour ce wire figé. Les paramètres Argon2 de création, KDF/AEAD effectifs, payloads et vérification Ed25519 restent `pre.004+`. +`0.2.5-pre.004` matérialise les primitives cryptographiques in-memory sans encore créer/ouvrir un wallet complet : Argon2id version 19 dérive une KEK de 32 octets depuis le password et les paramètres sérialisés, XChaCha20-Poly1305 wrappe/déwrappe les content keys avec les AAD figés, `getrandom` fournit le CSPRNG OS et les buffers secrets possédés sont zeroized. Un vecteur public test-only fixe l'interop KDF+AEAD. Le profil Argon2 de création n'est pas inventé : trois candidats sont benchmarkables par un test `#[ignore]` et le gate `pre.005` attend le résultat opérateur avant de figer le default. + ## `0.2.6` — Wallet Desk Mission : valider Config composite + `.kspwallet` + transport HTTP dans une application Tauri mince. diff --git a/docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md b/docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md index cec305e..083fedd 100644 --- a/docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md +++ b/docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md @@ -1,5 +1,5 @@ - + # Plan `0.2.5` — Wallet foundation @@ -392,9 +392,9 @@ Audit source notable : `solana-keypair 3.1.2` contient un bloc `unsafe` interne | scrypt | `0.12.0` | alternative maintenue, non ajoutée | | PBKDF2 | `0.13.0` | compatibilité/legacy seulement, non ajouté | -Les paramètres Argon2 de création seront benchmarkés sur les machines cibles. Ils ne sont pas copiés de bot3, d'un RFC ou d'un default de crate. Le fichier sérialise tous les paramètres nécessaires afin qu'un ancien wallet conserve son profil historique. +Les paramètres Argon2 de création sont benchmarkés sur les machines cibles. `pre.004` livre trois candidats explicites (64 MiB / 128 MiB / 256 MiB, trois passes, une lane) et un test opérateur ignoré par défaut ; **aucun candidat n'est déclaré default avant retour du benchmark opérateur**. Ils ne sont pas copiés de bot3, d'un RFC ou d'un default de crate. Le fichier sérialise tous les paramètres nécessaires afin qu'un ancien wallet conserve son profil historique. -Le parseur impose des **bornes maximales** avant de lancer le KDF, afin qu'un fichier hostile ne puisse demander arbitrairement mémoire/CPU. Les valeurs exactes de création et de rejet sont gelées en `pre.004` après benchmark. +Le parseur impose des **bornes maximales** avant de lancer le KDF, afin qu'un fichier hostile ne puisse demander arbitrairement mémoire/CPU. `pre.004` ajoute également l'invariant Argon2 `memory_kib >= 8 * parallelism` avant tout calcul coûteux. Les bornes structurelles restent indépendantes du default de création ; celui-ci est figé seulement après le benchmark opérateur de `pre.004`. ### 8.3 AEAD @@ -1197,4 +1197,4 @@ Une future `format_version >= 2` pourra réétudier des facteurs/ancrages extern ## 26. Suite immédiate -`0.2.5-pre.003` fige désormais le codec JSON strict, les limites structurelles, `slot_id` 16 octets, le descripteur VIEW, les DTOs d’enveloppe/key slots, les TLV transcript/AAD et la première spécification `docs/formats/KSPWALLET_V1.md`. La fixture `kspwallet_v1_wire_only.json` est volontairement structurelle et non cryptographiquement valide. La suite immédiate devient `pre.004` : benchmark des defaults Argon2 puis KDF/AEAD/wrapping/CSPRNG effectifs et premiers vecteurs cryptographiques publics. +`0.2.5-pre.003` fige le codec JSON strict, les limites structurelles, `slot_id` 16 octets, le descripteur VIEW, les DTOs d’enveloppe/key slots, les TLV transcript/AAD et la première spécification `docs/formats/KSPWALLET_V1.md`. `0.2.5-pre.004` ajoute les primitives effectives Argon2id v19, XChaCha20-Poly1305, CSPRNG OS, wrapping de content keys, zeroization des clés possédées et un vecteur crypto public déterministe. La seule partie de `pre.004` qui reste volontairement à confirmer par l’opérateur est le choix du **default de création Argon2**, via le benchmark candidat livré et ignoré par défaut. `pre.005` ne doit pas figer ce default sans ce résultat.