From c6794af05b2c4f5ed2e2829e3237f8abbce6ad5b Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Wed, 19 Aug 2026 10:52:21 +0200 Subject: [PATCH] v0.2.5-pre.003 --- Cargo.toml | 5 +- ROADMAP.md | 4 +- crates/ksp-wallet-lib/Cargo.toml | 5 +- crates/ksp-wallet-lib/src/constants.rs | 65 +- crates/ksp-wallet-lib/src/lib.rs | 103 +- crates/ksp-wallet-lib/src/transcript.rs | 131 +++ crates/ksp-wallet-lib/src/wire.rs | 1024 +++++++++++++++++ .../tests/dependency_boundary.rs | 5 +- .../fixtures/kspwallet_v1_wire_only.json | 67 ++ crates/ksp-wallet-lib/tests/public_api.rs | 19 +- .../ksp-wallet-lib/unit_tests/transcript.rs | 71 ++ crates/ksp-wallet-lib/unit_tests/wire.rs | 126 ++ deltas/0.2.5/pre.003.md | 439 +++++++ docs/000-README.md | 11 +- docs/formats/000-README.md | 12 + docs/formats/KSPWALLET_V1.md | 617 ++++++++++ docs/plans/000-README.md | 4 +- docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md | 4 +- .../012-V0_2_5_WALLET_FOUNDATION_PLAN.md | 18 +- docs/rules/FILE_CONTRACTS.md | 28 +- 20 files changed, 2721 insertions(+), 37 deletions(-) create mode 100644 crates/ksp-wallet-lib/src/transcript.rs create mode 100644 crates/ksp-wallet-lib/src/wire.rs create mode 100644 crates/ksp-wallet-lib/tests/fixtures/kspwallet_v1_wire_only.json create mode 100644 crates/ksp-wallet-lib/unit_tests/transcript.rs create mode 100644 crates/ksp-wallet-lib/unit_tests/wire.rs create mode 100644 deltas/0.2.5/pre.003.md create mode 100644 docs/formats/000-README.md create mode 100644 docs/formats/KSPWALLET_V1.md diff --git a/Cargo.toml b/Cargo.toml index 7a3d7b9..02ff8fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 145 +# version: 146 [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.2.fix.2" +version = "0.2.5-pre.3" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" @@ -14,6 +14,7 @@ authors = ["SinuS von SifriduS "] publish = false [workspace.dependencies] +base64 = { version = "^0.23" } fs2 = { version = "^0.4" } serde = { version = "^1.0" } serde_json = { version = "^1.0" } diff --git a/ROADMAP.md b/ROADMAP.md index cb2d673..16da642 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, projections metadata protégées, wrappers password redacted/zeroized, erreurs Wallet, target `ksp-wallet-lib` via `ksp-logging-lib` et canaries de frontières. `Pubkey` est consommée uniquement via `ksp-core-lib`, sans dépendance directe `solana-pubkey`; Config/Transport/ExecutionPolicy/Store/Tauri restent hors Wallet. Le wire/crypto/persistence/signature/rotations/import-export restent répartis 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 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.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 7371c00..d6ecae9 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: 1 +# version: 2 [package] name = "ksp-wallet-lib" @@ -10,6 +10,9 @@ repository.workspace = true [dependencies] ksp-core-lib = { path = "../ksp-core-lib" } ksp-logging-lib = { path = "../ksp-logging-lib" } +base64.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true zeroize.workspace = true [lints] diff --git a/crates/ksp-wallet-lib/src/constants.rs b/crates/ksp-wallet-lib/src/constants.rs index 122dbf1..b2ca1a7 100644 --- a/crates/ksp-wallet-lib/src/constants.rs +++ b/crates/ksp-wallet-lib/src/constants.rs @@ -1,7 +1,70 @@ // file: crates/ksp-wallet-lib/src/constants.rs -// version: 1 +// version: 3 //! Wallet-owned constants. +/// Exact magic string required by every native `.kspwallet` document. +pub const KSPWALLET_MAGIC: &str = "KSPWALLET"; +/// Native Wallet format version implemented by the V1 codec. +pub const KSPWALLET_FORMAT_VERSION_V1: u32 = 1; +/// Maximum accepted `.kspwallet` document size before JSON parsing. +pub const KSPWALLET_MAX_FILE_BYTES: usize = 1024 * 1024; +/// Maximum protected alias size in UTF-8 bytes for metadata V1. +pub const KSPWALLET_V1_MAX_ALIAS_BYTES: usize = 256; +/// Maximum number of protected notes in metadata V1. +pub const KSPWALLET_V1_MAX_NOTES: usize = 64; +/// Maximum protected note text size in UTF-8 bytes for metadata V1. +pub const KSPWALLET_V1_MAX_NOTE_TEXT_BYTES: usize = 8 * 1024; +/// Maximum metadata plaintext size before V1 encryption. +pub const KSPWALLET_V1_MAX_METADATA_PLAINTEXT_BYTES: usize = 64 * 1024; +/// Maximum password size in exact UTF-8 input bytes. +pub const KSPWALLET_V1_MAX_PASSWORD_BYTES: usize = 1024; +/// Byte length of every V1 key-slot identifier. +pub const KSPWALLET_V1_SLOT_ID_BYTES: usize = 16; +/// Byte length of an Ed25519 public key used as Wallet format authority. +pub const KSPWALLET_V1_ED25519_PUBLIC_KEY_BYTES: usize = 32; +/// Byte length of an Ed25519 detached state signature. +pub const KSPWALLET_V1_ED25519_SIGNATURE_BYTES: usize = 64; +/// Byte length of an XChaCha20-Poly1305 nonce. +pub const KSPWALLET_V1_XCHACHA_NONCE_BYTES: usize = 24; +/// Byte length of the Poly1305 authentication tag appended to each ciphertext. +pub const KSPWALLET_V1_AEAD_TAG_BYTES: usize = 16; +/// Argon2 version serialized by V1 key slots. +pub const KSPWALLET_V1_ARGON2_VERSION: u32 = 19; +/// Minimum accepted Argon2 salt size in bytes. +pub const KSPWALLET_V1_MIN_KDF_SALT_BYTES: usize = 16; +/// Maximum accepted Argon2 salt size in bytes. +pub const KSPWALLET_V1_MAX_KDF_SALT_BYTES: usize = 64; +/// Structural V1 ceiling for serialized Argon2 memory cost; creation defaults are benchmarked separately. +pub const KSPWALLET_V1_MAX_ARGON2_MEMORY_KIB: u32 = 1024 * 1024; +/// Structural V1 ceiling for serialized Argon2 iteration cost; creation defaults are benchmarked separately. +pub const KSPWALLET_V1_MAX_ARGON2_ITERATIONS: u32 = 64; +/// Structural V1 ceiling for serialized Argon2 parallelism; creation defaults are benchmarked separately. +pub const KSPWALLET_V1_MAX_ARGON2_PARALLELISM: u32 = 64; +/// Maximum number of key slots understood by format V1: exactly one OWNER plus optional VIEW. +pub const KSPWALLET_V1_MAX_KEY_SLOTS: usize = 2; +/// Maximum ciphertext size of one wrapped capability payload. +pub const KSPWALLET_V1_MAX_KEY_WRAP_CIPHERTEXT_BYTES: usize = 4096; +/// Maximum OWNER-control ciphertext size accepted by envelope V1. +pub const KSPWALLET_V1_MAX_OWNER_CONTROL_CIPHERTEXT_BYTES: usize = 4096; +/// Maximum metadata ciphertext size, including the AEAD tag over the bounded 64-KiB plaintext. +pub const KSPWALLET_V1_MAX_METADATA_CIPHERTEXT_BYTES: usize = KSPWALLET_V1_MAX_METADATA_PLAINTEXT_BYTES + KSPWALLET_V1_AEAD_TAG_BYTES; +/// Maximum OWNER-only secret ciphertext size accepted by envelope V1. +pub const KSPWALLET_V1_MAX_SECRET_CIPHERTEXT_BYTES: usize = 4096; +/// Initial protected payload version used independently by control, metadata and secret compartments. +pub const KSPWALLET_V1_INITIAL_PAYLOAD_VERSION: u32 = 1; +/// Domain separator for the OWNER state-signature transcript. +pub const KSPWALLET_V1_STATE_TRANSCRIPT_DOMAIN: &[u8] = b"KSPWALLET-V1-STATE"; +/// Domain separator for OWNER key-slot wrapping AAD. +pub const KSPWALLET_V1_OWNER_SLOT_AAD_DOMAIN: &[u8] = b"KSPWALLET-V1-AAD-OWNER-SLOT"; +/// Domain separator for VIEW key-slot wrapping AAD. +pub const KSPWALLET_V1_VIEW_SLOT_AAD_DOMAIN: &[u8] = b"KSPWALLET-V1-AAD-VIEW-SLOT"; +/// Domain separator for OWNER-control compartment AAD. +pub const KSPWALLET_V1_OWNER_CONTROL_AAD_DOMAIN: &[u8] = b"KSPWALLET-V1-AAD-OWNER-CONTROL"; +/// Domain separator for metadata compartment AAD. +pub const KSPWALLET_V1_METADATA_AAD_DOMAIN: &[u8] = b"KSPWALLET-V1-AAD-METADATA"; +/// Domain separator for OWNER-only secret compartment AAD. +pub const KSPWALLET_V1_SECRET_AAD_DOMAIN: &[u8] = b"KSPWALLET-V1-AAD-SECRET"; + /// Owning tracing target for events emitted by the Wallet crate. pub(crate) const TRACING_TARGET: &str = "ksp-wallet-lib"; diff --git a/crates/ksp-wallet-lib/src/lib.rs b/crates/ksp-wallet-lib/src/lib.rs index f53c357..62a89a6 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: 1 +// version: 3 #![warn(missing_docs)] #![deny(unreachable_pub)] #![forbid(unsafe_code)] @@ -7,9 +7,10 @@ //! 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. The `0.2.5-pre.002` foundation deliberately contains no file codec, KDF/AEAD implementation, Solana secret material, persistence, -//! network access, Config integration or execution policy. 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` 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`. mod capability; mod constants; @@ -17,10 +18,74 @@ mod error; mod metadata; mod owner; mod password; +mod transcript; mod view; +mod wire; /// Authorized capability represented by an unlocked Wallet handle. pub use self::capability::WalletCapability; +/// Native `.kspwallet` V1 format version. +pub use self::constants::KSPWALLET_FORMAT_VERSION_V1; +/// Exact magic string required by every native `.kspwallet` document. +pub use self::constants::KSPWALLET_MAGIC; +/// Maximum accepted `.kspwallet` document size before parsing. +pub use self::constants::KSPWALLET_MAX_FILE_BYTES; +/// Byte length of the AEAD authentication tag appended to V1 ciphertexts. +pub use self::constants::KSPWALLET_V1_AEAD_TAG_BYTES; +/// Argon2 version serialized by `.kspwallet` V1 key slots. +pub use self::constants::KSPWALLET_V1_ARGON2_VERSION; +/// Byte length of the Ed25519 format-authority public key. +pub use self::constants::KSPWALLET_V1_ED25519_PUBLIC_KEY_BYTES; +/// Byte length of the Ed25519 detached state signature. +pub use self::constants::KSPWALLET_V1_ED25519_SIGNATURE_BYTES; +/// Initial per-compartment protected payload version. +pub use self::constants::KSPWALLET_V1_INITIAL_PAYLOAD_VERSION; +/// Maximum alias size in protected metadata V1. +pub use self::constants::KSPWALLET_V1_MAX_ALIAS_BYTES; +/// Maximum structural Argon2 iteration count accepted by the V1 parser. +pub use self::constants::KSPWALLET_V1_MAX_ARGON2_ITERATIONS; +/// Maximum structural Argon2 memory cost accepted by the V1 parser. +pub use self::constants::KSPWALLET_V1_MAX_ARGON2_MEMORY_KIB; +/// Maximum structural Argon2 parallelism accepted by the V1 parser. +pub use self::constants::KSPWALLET_V1_MAX_ARGON2_PARALLELISM; +/// Maximum Argon2 salt size accepted by V1. +pub use self::constants::KSPWALLET_V1_MAX_KDF_SALT_BYTES; +/// Maximum number of OWNER/VIEW slots accepted by format V1. +pub use self::constants::KSPWALLET_V1_MAX_KEY_SLOTS; +/// Maximum wrapped-key ciphertext size accepted by V1. +pub use self::constants::KSPWALLET_V1_MAX_KEY_WRAP_CIPHERTEXT_BYTES; +/// Maximum protected metadata ciphertext size accepted by V1. +pub use self::constants::KSPWALLET_V1_MAX_METADATA_CIPHERTEXT_BYTES; +/// Maximum protected metadata plaintext size defined by V1. +pub use self::constants::KSPWALLET_V1_MAX_METADATA_PLAINTEXT_BYTES; +/// Maximum protected note text size defined by metadata V1. +pub use self::constants::KSPWALLET_V1_MAX_NOTE_TEXT_BYTES; +/// Maximum number of protected notes defined by metadata V1. +pub use self::constants::KSPWALLET_V1_MAX_NOTES; +/// Maximum OWNER-control ciphertext size accepted by V1. +pub use self::constants::KSPWALLET_V1_MAX_OWNER_CONTROL_CIPHERTEXT_BYTES; +/// Maximum password size in exact UTF-8 bytes defined by V1. +pub use self::constants::KSPWALLET_V1_MAX_PASSWORD_BYTES; +/// Maximum OWNER-only secret ciphertext size accepted by V1. +pub use self::constants::KSPWALLET_V1_MAX_SECRET_CIPHERTEXT_BYTES; +/// Domain separator for metadata-compartment AEAD AAD. +pub use self::constants::KSPWALLET_V1_METADATA_AAD_DOMAIN; +/// Minimum Argon2 salt size accepted by V1. +pub use self::constants::KSPWALLET_V1_MIN_KDF_SALT_BYTES; +/// Domain separator for OWNER-control compartment AEAD AAD. +pub use self::constants::KSPWALLET_V1_OWNER_CONTROL_AAD_DOMAIN; +/// Domain separator for OWNER key-slot wrapping AAD. +pub use self::constants::KSPWALLET_V1_OWNER_SLOT_AAD_DOMAIN; +/// Domain separator for OWNER-only secret compartment AEAD AAD. +pub use self::constants::KSPWALLET_V1_SECRET_AAD_DOMAIN; +/// Byte length of every V1 key-slot identifier. +pub use self::constants::KSPWALLET_V1_SLOT_ID_BYTES; +/// Domain separator for the OWNER state-signature transcript. +pub use self::constants::KSPWALLET_V1_STATE_TRANSCRIPT_DOMAIN; +/// Domain separator for VIEW key-slot wrapping AAD. +pub use self::constants::KSPWALLET_V1_VIEW_SLOT_AAD_DOMAIN; +/// Byte length of an XChaCha20-Poly1305 nonce. +pub use self::constants::KSPWALLET_V1_XCHACHA_NONCE_BYTES; /// Error code used when an atomic Wallet persistence operation cannot publish a valid replacement. pub use self::error::ERROR_CODE_ATOMIC_PERSISTENCE_FAILED; /// Error code used when an authenticated Wallet structure cannot be verified. @@ -61,6 +126,36 @@ pub use self::password::OwnerPassword; pub use self::password::ViewPassword; /// Authorized VIEW capability handle. pub use self::view::WalletView; +/// Strict semantic representation of one parsed native `.kspwallet` V1 envelope. +pub use self::wire::KspWalletEnvelopeV1; +/// Authenticated-encryption algorithm fixed by native Wallet V1. +pub use self::wire::WalletAeadAlgorithmV1; +/// Protected compartment kind fixed by native Wallet V1. +pub use self::wire::WalletCompartmentKindV1; +/// One validated encrypted V1 compartment. +pub use self::wire::WalletEncryptedCompartmentV1; +/// Password KDF fixed by native Wallet V1. +pub use self::wire::WalletKdfAlgorithmV1; +/// Parsed Argon2id parameters from one V1 key slot. +pub use self::wire::WalletKdfParametersV1; +/// Role of one native V1 key slot. +pub use self::wire::WalletKeySlotRoleV1; +/// One validated OWNER or VIEW V1 key slot. +pub use self::wire::WalletKeySlotV1; +/// Parsed AEAD wrapped-key payload from one V1 key slot. +pub use self::wire::WalletKeyWrapV1; +/// State-signature algorithm fixed by native Wallet V1. +pub use self::wire::WalletStateSignatureAlgorithmV1; +/// Detached OWNER state signature embedded in a V1 envelope. +pub use self::wire::WalletStateSignatureV1; +/// OWNER-signed stable descriptor of the optional VIEW slot. +pub use self::wire::WalletViewDescriptorV1; /// Wallet-owned tracing target used by the KSP logging facade. pub(crate) use self::constants::TRACING_TARGET; +/// Internal deterministic compartment-AAD codec shared by Wallet crypto layers. +pub(crate) use self::transcript::compartment_aad; +/// Internal deterministic key-slot-AAD codec shared by Wallet crypto layers. +pub(crate) use self::transcript::slot_aad; +/// Internal deterministic OWNER-state transcript codec shared by Wallet crypto layers. +pub(crate) use self::transcript::state_transcript; diff --git a/crates/ksp-wallet-lib/src/transcript.rs b/crates/ksp-wallet-lib/src/transcript.rs new file mode 100644 index 0000000..e248a70 --- /dev/null +++ b/crates/ksp-wallet-lib/src/transcript.rs @@ -0,0 +1,131 @@ +// file: crates/ksp-wallet-lib/src/transcript.rs +// version: 1 + +//! Deterministic `.kspwallet` V1 state-transcript and AEAD-AAD encoding. + +const TAG_MAGIC: u16 = 0x0001; +const TAG_FORMAT_VERSION: u16 = 0x0002; +const TAG_OWNER_AUTH_PUBLIC_KEY: u16 = 0x0003; +const TAG_VIEW_ENABLED: u16 = 0x0010; +const TAG_VIEW_ROLE: u16 = 0x0011; +const TAG_VIEW_SLOT_ID: u16 = 0x0012; +const TAG_SLOT_ID: u16 = 0x0100; +const TAG_SLOT_ROLE: u16 = 0x0101; +const TAG_KDF_ALGORITHM: u16 = 0x0102; +const TAG_KDF_VERSION: u16 = 0x0103; +const TAG_KDF_MEMORY_KIB: u16 = 0x0104; +const TAG_KDF_ITERATIONS: u16 = 0x0105; +const TAG_KDF_PARALLELISM: u16 = 0x0106; +const TAG_KDF_SALT: u16 = 0x0107; +const TAG_WRAP_ALGORITHM: u16 = 0x0108; +const TAG_WRAP_NONCE: u16 = 0x0109; +const TAG_WRAP_CIPHERTEXT: u16 = 0x010A; +const TAG_COMPARTMENT_KIND: u16 = 0x0200; +const TAG_COMPARTMENT_VERSION: u16 = 0x0201; +const TAG_COMPARTMENT_ALGORITHM: u16 = 0x0202; +const TAG_COMPARTMENT_NONCE: u16 = 0x0203; +const TAG_COMPARTMENT_CIPHERTEXT: u16 = 0x0204; +const TAG_STATE_SIGNATURE_ALGORITHM: u16 = 0x0500; + +/// Builds the normative OWNER state-signature transcript for one validated V1 envelope. +pub(crate) fn state_transcript(envelope: &crate::KspWalletEnvelopeV1) -> std::vec::Vec { + let mut output = start(crate::KSPWALLET_V1_STATE_TRANSCRIPT_DOMAIN); + push_common(&mut output, envelope); + push_bool(&mut output, TAG_VIEW_ENABLED, envelope.view_descriptor().enabled()); + push_bytes(&mut output, TAG_VIEW_ROLE, crate::WalletKeySlotRoleV1::View.as_str().as_bytes()); + match envelope.view_descriptor().slot_id() { + std::option::Option::Some(slot_id) => push_bytes(&mut output, TAG_VIEW_SLOT_ID, slot_id), + std::option::Option::None => push_bytes(&mut output, TAG_VIEW_SLOT_ID, &[]), + } + + push_slot(&mut output, envelope.owner_slot(), true); + push_compartment(&mut output, envelope.owner_control(), true); + push_compartment(&mut output, envelope.metadata(), true); + push_compartment(&mut output, envelope.secret(), true); + push_bytes(&mut output, TAG_STATE_SIGNATURE_ALGORITHM, envelope.state_signature().algorithm().as_str().as_bytes()); + return output; +} + +/// Builds the normative wrapping AAD for one validated OWNER or VIEW key slot. +pub(crate) fn slot_aad(envelope: &crate::KspWalletEnvelopeV1, slot: &crate::WalletKeySlotV1) -> std::vec::Vec { + let domain = match slot.role() { + crate::WalletKeySlotRoleV1::Owner => crate::KSPWALLET_V1_OWNER_SLOT_AAD_DOMAIN, + crate::WalletKeySlotRoleV1::View => crate::KSPWALLET_V1_VIEW_SLOT_AAD_DOMAIN, + }; + let mut output = start(domain); + push_common(&mut output, envelope); + push_slot(&mut output, slot, false); + return output; +} + +/// Builds the normative AEAD AAD for one validated encrypted compartment. +pub(crate) fn compartment_aad(envelope: &crate::KspWalletEnvelopeV1, compartment: &crate::WalletEncryptedCompartmentV1) -> std::vec::Vec { + let domain = match compartment.kind() { + crate::WalletCompartmentKindV1::OwnerControl => crate::KSPWALLET_V1_OWNER_CONTROL_AAD_DOMAIN, + crate::WalletCompartmentKindV1::Metadata => crate::KSPWALLET_V1_METADATA_AAD_DOMAIN, + crate::WalletCompartmentKindV1::Secret => crate::KSPWALLET_V1_SECRET_AAD_DOMAIN, + }; + let mut output = start(domain); + push_common(&mut output, envelope); + push_compartment(&mut output, compartment, false); + return output; +} + +fn start(domain: &[u8]) -> std::vec::Vec { + let mut output = std::vec::Vec::with_capacity(512); + output.extend_from_slice(domain); + output.push(0); + return output; +} + +fn push_common(output: &mut std::vec::Vec, envelope: &crate::KspWalletEnvelopeV1) { + push_bytes(output, TAG_MAGIC, crate::KSPWALLET_MAGIC.as_bytes()); + push_u32(output, TAG_FORMAT_VERSION, envelope.format_version()); + push_bytes(output, TAG_OWNER_AUTH_PUBLIC_KEY, envelope.owner_auth_public_key()); +} + +fn push_slot(output: &mut std::vec::Vec, slot: &crate::WalletKeySlotV1, include_wrap_payload: bool) { + push_bytes(output, TAG_SLOT_ID, slot.slot_id()); + push_bytes(output, TAG_SLOT_ROLE, slot.role().as_str().as_bytes()); + push_bytes(output, TAG_KDF_ALGORITHM, slot.kdf().algorithm().as_str().as_bytes()); + push_u32(output, TAG_KDF_VERSION, slot.kdf().version()); + push_u32(output, TAG_KDF_MEMORY_KIB, slot.kdf().memory_kib()); + push_u32(output, TAG_KDF_ITERATIONS, slot.kdf().iterations()); + push_u32(output, TAG_KDF_PARALLELISM, slot.kdf().parallelism()); + push_bytes(output, TAG_KDF_SALT, slot.kdf().salt()); + push_bytes(output, TAG_WRAP_ALGORITHM, slot.wrap().algorithm().as_str().as_bytes()); + if include_wrap_payload { + push_bytes(output, TAG_WRAP_NONCE, slot.wrap().nonce()); + push_bytes(output, TAG_WRAP_CIPHERTEXT, slot.wrap().ciphertext()); + } +} + +fn push_compartment(output: &mut std::vec::Vec, compartment: &crate::WalletEncryptedCompartmentV1, include_ciphertext: bool) { + push_bytes(output, TAG_COMPARTMENT_KIND, compartment.kind().as_str().as_bytes()); + push_u32(output, TAG_COMPARTMENT_VERSION, compartment.payload_version()); + push_bytes(output, TAG_COMPARTMENT_ALGORITHM, compartment.algorithm().as_str().as_bytes()); + if include_ciphertext { + push_bytes(output, TAG_COMPARTMENT_NONCE, compartment.nonce()); + push_bytes(output, TAG_COMPARTMENT_CIPHERTEXT, compartment.ciphertext()); + } +} + +fn push_bool(output: &mut std::vec::Vec, tag: u16, value: bool) { + let byte = if value { 1_u8 } else { 0_u8 }; + push_bytes(output, tag, &[byte]); +} + +fn push_u32(output: &mut std::vec::Vec, tag: u16, value: u32) { + push_bytes(output, tag, value.to_be_bytes().as_slice()); +} + +fn push_bytes(output: &mut std::vec::Vec, tag: u16, value: &[u8]) { + output.extend_from_slice(tag.to_be_bytes().as_slice()); + let length = value.len() as u64; + output.extend_from_slice(length.to_be_bytes().as_slice()); + output.extend_from_slice(value); +} + +#[cfg(test)] +#[path = "../unit_tests/transcript.rs"] +mod tests; diff --git a/crates/ksp-wallet-lib/src/wire.rs b/crates/ksp-wallet-lib/src/wire.rs new file mode 100644 index 0000000..8d3b958 --- /dev/null +++ b/crates/ksp-wallet-lib/src/wire.rs @@ -0,0 +1,1024 @@ +// file: crates/ksp-wallet-lib/src/wire.rs +// version: 1 + +//! Strict native `.kspwallet` V1 wire envelope. + +use base64::Engine as _; + +/// Password KDF supported by `.kspwallet` V1 key slots. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WalletKdfAlgorithmV1 { + /// Argon2id version 19. + Argon2id, +} + +impl WalletKdfAlgorithmV1 { + /// Returns the normative wire identifier. + #[must_use] + pub const fn as_str(self) -> &'static str { + return "argon2id"; + } +} + +/// Authenticated-encryption algorithm supported by `.kspwallet` V1. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WalletAeadAlgorithmV1 { + /// XChaCha20-Poly1305 with a 24-byte nonce. + XChaCha20Poly1305, +} + +impl WalletAeadAlgorithmV1 { + /// Returns the normative wire identifier. + #[must_use] + pub const fn as_str(self) -> &'static str { + return "xchacha20-poly1305"; + } +} + +/// State-signature algorithm supported by `.kspwallet` V1. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WalletStateSignatureAlgorithmV1 { + /// Ed25519 detached signature. + Ed25519, +} + +impl WalletStateSignatureAlgorithmV1 { + /// Returns the normative wire identifier. + #[must_use] + pub const fn as_str(self) -> &'static str { + return "ed25519"; + } +} + +/// Role of one V1 password-protected key slot. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WalletKeySlotRoleV1 { + /// OWNER slot giving access to the OWNER/root capability. + Owner, + /// VIEW slot giving access only to protected metadata and self-service VIEW password rotation. + View, +} + +impl WalletKeySlotRoleV1 { + /// Returns the normative wire identifier. + #[must_use] + pub const fn as_str(self) -> &'static str { + return match self { + Self::Owner => "owner", + Self::View => "view", + }; + } +} + +/// Protected V1 payload compartment kind. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WalletCompartmentKindV1 { + /// OWNER control material, including the format-administration private material. + OwnerControl, + /// VIEW/OWNER-readable Solana Pubkey, alias and notes. + Metadata, + /// OWNER-only Solana keypair material. + Secret, +} + +impl WalletCompartmentKindV1 { + /// Returns the stable transcript/AAD identifier. + #[must_use] + pub const fn as_str(self) -> &'static str { + return match self { + Self::OwnerControl => "owner-control", + Self::Metadata => "metadata", + Self::Secret => "secret", + }; + } +} + +/// Parsed Argon2id parameters embedded in one V1 key slot. +#[derive(Clone, Eq, PartialEq)] +pub struct WalletKdfParametersV1 { + algorithm: WalletKdfAlgorithmV1, + version: u32, + memory_kib: u32, + iterations: u32, + parallelism: u32, + salt: std::vec::Vec, +} + +impl WalletKdfParametersV1 { + /// Returns the KDF algorithm. + #[must_use] + pub const fn algorithm(&self) -> WalletKdfAlgorithmV1 { + return self.algorithm; + } + + /// Returns the serialized Argon2 version. + #[must_use] + pub const fn version(&self) -> u32 { + return self.version; + } + + /// Returns the Argon2 memory cost in KiB. + #[must_use] + pub const fn memory_kib(&self) -> u32 { + return self.memory_kib; + } + + /// Returns the Argon2 iteration/time cost. + #[must_use] + pub const fn iterations(&self) -> u32 { + return self.iterations; + } + + /// Returns the Argon2 parallelism cost. + #[must_use] + pub const fn parallelism(&self) -> u32 { + return self.parallelism; + } + + /// Returns the public KDF salt bytes. + #[must_use] + pub fn salt(&self) -> &[u8] { + return self.salt.as_slice(); + } +} + +impl std::fmt::Debug for WalletKdfParametersV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter + .debug_struct("WalletKdfParametersV1") + .field("algorithm", &self.algorithm) + .field("version", &self.version) + .field("memory_kib", &self.memory_kib) + .field("iterations", &self.iterations) + .field("parallelism", &self.parallelism) + .field("salt_bytes", &self.salt.len()) + .finish(); + } +} + +/// Parsed AEAD wrapping payload embedded in one V1 key slot. +#[derive(Clone, Eq, PartialEq)] +pub struct WalletKeyWrapV1 { + algorithm: WalletAeadAlgorithmV1, + nonce: [u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES], + ciphertext: std::vec::Vec, +} + +impl WalletKeyWrapV1 { + /// Returns the wrapping AEAD algorithm. + #[must_use] + pub const fn algorithm(&self) -> WalletAeadAlgorithmV1 { + return self.algorithm; + } + + /// Returns the public AEAD nonce. + #[must_use] + pub const fn nonce(&self) -> &[u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES] { + return &self.nonce; + } + + /// Returns the wrapped-key ciphertext bytes. + #[must_use] + pub fn ciphertext(&self) -> &[u8] { + return self.ciphertext.as_slice(); + } +} + +impl std::fmt::Debug for WalletKeyWrapV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter + .debug_struct("WalletKeyWrapV1") + .field("algorithm", &self.algorithm) + .field("nonce_bytes", &self.nonce.len()) + .field("ciphertext_bytes", &self.ciphertext.len()) + .finish(); + } +} + +/// One validated OWNER or VIEW key slot from a V1 envelope. +#[derive(Clone, Eq, PartialEq)] +pub struct WalletKeySlotV1 { + slot_id: [u8; crate::KSPWALLET_V1_SLOT_ID_BYTES], + role: WalletKeySlotRoleV1, + kdf: WalletKdfParametersV1, + wrap: WalletKeyWrapV1, +} + +impl WalletKeySlotV1 { + /// Returns the stable 16-byte slot identifier. + #[must_use] + pub const fn slot_id(&self) -> &[u8; crate::KSPWALLET_V1_SLOT_ID_BYTES] { + return &self.slot_id; + } + + /// Returns the key-slot role. + #[must_use] + pub const fn role(&self) -> WalletKeySlotRoleV1 { + return self.role; + } + + /// Returns the serialized password-KDF parameters. + #[must_use] + pub const fn kdf(&self) -> &WalletKdfParametersV1 { + return &self.kdf; + } + + /// Returns the wrapped capability payload. + #[must_use] + pub const fn wrap(&self) -> &WalletKeyWrapV1 { + return &self.wrap; + } +} + +impl std::fmt::Debug for WalletKeySlotV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter + .debug_struct("WalletKeySlotV1") + .field("slot_id_bytes", &self.slot_id.len()) + .field("role", &self.role) + .field("kdf", &self.kdf) + .field("wrap", &self.wrap) + .finish(); + } +} + +/// OWNER-signed descriptor that binds the optional self-service VIEW slot. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WalletViewDescriptorV1 { + enabled: bool, + slot_id: std::option::Option<[u8; crate::KSPWALLET_V1_SLOT_ID_BYTES]>, +} + +impl WalletViewDescriptorV1 { + /// Reports whether a VIEW slot is enabled. + #[must_use] + pub const fn enabled(&self) -> bool { + return self.enabled; + } + + /// Returns the signed VIEW slot identifier when VIEW is enabled. + #[must_use] + pub const fn slot_id(&self) -> std::option::Option<&[u8; crate::KSPWALLET_V1_SLOT_ID_BYTES]> { + return self.slot_id.as_ref(); + } +} + +/// One validated encrypted compartment from a V1 envelope. +#[derive(Clone, Eq, PartialEq)] +pub struct WalletEncryptedCompartmentV1 { + kind: WalletCompartmentKindV1, + payload_version: u32, + algorithm: WalletAeadAlgorithmV1, + nonce: [u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES], + ciphertext: std::vec::Vec, +} + +impl WalletEncryptedCompartmentV1 { + /// Returns the compartment kind. + #[must_use] + pub const fn kind(&self) -> WalletCompartmentKindV1 { + return self.kind; + } + + /// Returns the protected payload version for this compartment. + #[must_use] + pub const fn payload_version(&self) -> u32 { + return self.payload_version; + } + + /// Returns the compartment AEAD algorithm. + #[must_use] + pub const fn algorithm(&self) -> WalletAeadAlgorithmV1 { + return self.algorithm; + } + + /// Returns the public AEAD nonce. + #[must_use] + pub const fn nonce(&self) -> &[u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES] { + return &self.nonce; + } + + /// Returns the encrypted payload bytes. + #[must_use] + pub fn ciphertext(&self) -> &[u8] { + return self.ciphertext.as_slice(); + } +} + +impl std::fmt::Debug for WalletEncryptedCompartmentV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter + .debug_struct("WalletEncryptedCompartmentV1") + .field("kind", &self.kind) + .field("payload_version", &self.payload_version) + .field("algorithm", &self.algorithm) + .field("nonce_bytes", &self.nonce.len()) + .field("ciphertext_bytes", &self.ciphertext.len()) + .finish(); + } +} + +/// Parsed detached OWNER state signature from a V1 envelope. +#[derive(Clone, Eq, PartialEq)] +pub struct WalletStateSignatureV1 { + algorithm: WalletStateSignatureAlgorithmV1, + signature: [u8; crate::KSPWALLET_V1_ED25519_SIGNATURE_BYTES], +} + +impl WalletStateSignatureV1 { + /// Returns the state-signature algorithm. + #[must_use] + pub const fn algorithm(&self) -> WalletStateSignatureAlgorithmV1 { + return self.algorithm; + } + + /// Returns the detached signature bytes. + #[must_use] + pub const fn signature(&self) -> &[u8; crate::KSPWALLET_V1_ED25519_SIGNATURE_BYTES] { + return &self.signature; + } +} + +impl std::fmt::Debug for WalletStateSignatureV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter + .debug_struct("WalletStateSignatureV1") + .field("algorithm", &self.algorithm) + .field("signature_bytes", &self.signature.len()) + .finish(); + } +} + +/// Strict semantic representation of a parsed `.kspwallet` V1 envelope. +#[derive(Clone, Eq, PartialEq)] +pub struct KspWalletEnvelopeV1 { + owner_auth_public_key: [u8; crate::KSPWALLET_V1_ED25519_PUBLIC_KEY_BYTES], + view_descriptor: WalletViewDescriptorV1, + owner_slot: WalletKeySlotV1, + view_slot: std::option::Option, + owner_control: WalletEncryptedCompartmentV1, + metadata: WalletEncryptedCompartmentV1, + secret: WalletEncryptedCompartmentV1, + state_signature: WalletStateSignatureV1, +} + +impl KspWalletEnvelopeV1 { + /// Parses and semantically validates one complete `.kspwallet` V1 JSON document. + /// + /// This function validates only the V1 wire grammar, identifiers, canonical Base64url representation, structural bounds and key-slot invariants. + /// Cryptographic authentication/decryption is deliberately implemented by later Wallet layers. + pub fn parse_json(source: &[u8]) -> ksp_core_lib::Result { + if source.len() > crate::KSPWALLET_MAX_FILE_BYTES { + return std::result::Result::Err(format_error("Wallet document exceeds the V1 maximum size", "document")); + } + + let probe_result = serde_json::from_slice::(source); + let probe = match probe_result { + std::result::Result::Ok(probe) => probe, + std::result::Result::Err(error) => return std::result::Result::Err(json_parse_error(error)), + }; + if probe.magic != crate::KSPWALLET_MAGIC { + return std::result::Result::Err(format_error("Wallet magic is invalid", "magic")); + } + if probe.format_version != crate::KSPWALLET_FORMAT_VERSION_V1 { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_FORMAT_VERSION_UNSUPPORTED, "Wallet format version is not supported") + .with_context("format_version", probe.format_version.to_string()), + ); + } + + let raw_result = serde_json::from_slice::(source); + let raw = match raw_result { + std::result::Result::Ok(raw) => raw, + std::result::Result::Err(error) => return std::result::Result::Err(json_parse_error(error)), + }; + let envelope = match parse_raw_envelope(raw) { + std::result::Result::Ok(envelope) => envelope, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + ksp_logging_lib::trace!( + target: crate::TRACING_TARGET, + operation = "wallet_wire_parse", + format_version = envelope.format_version(), + view_enabled = envelope.view_descriptor().enabled(), + "native wallet V1 envelope parsed" + ); + return std::result::Result::Ok(envelope); + } + + /// Serializes this already-validated semantic envelope to canonical KSP pretty JSON plus one trailing newline. + pub fn to_json_bytes(&self) -> ksp_core_lib::Result> { + let raw = RawEnvelopeV1::from_envelope(self); + let serialized_result = serde_json::to_vec_pretty(&raw); + let mut serialized = match serialized_result { + std::result::Result::Ok(serialized) => serialized, + std::result::Result::Err(_) => { + return std::result::Result::Err(format_error("Validated Wallet envelope cannot be serialized", "document")); + }, + }; + serialized.push(b'\n'); + return std::result::Result::Ok(serialized); + } + + /// Returns the fixed native format version. + #[must_use] + pub const fn format_version(&self) -> u32 { + return crate::KSPWALLET_FORMAT_VERSION_V1; + } + + /// Returns the embedded Ed25519 public key that verifies OWNER-controlled state. + #[must_use] + pub const fn owner_auth_public_key(&self) -> &[u8; crate::KSPWALLET_V1_ED25519_PUBLIC_KEY_BYTES] { + return &self.owner_auth_public_key; + } + + /// Returns the OWNER-signed VIEW descriptor. + #[must_use] + pub const fn view_descriptor(&self) -> &WalletViewDescriptorV1 { + return &self.view_descriptor; + } + + /// Returns the unique OWNER slot. + #[must_use] + pub const fn owner_slot(&self) -> &WalletKeySlotV1 { + return &self.owner_slot; + } + + /// Returns the VIEW slot when enabled. + #[must_use] + pub const fn view_slot(&self) -> std::option::Option<&WalletKeySlotV1> { + return self.view_slot.as_ref(); + } + + /// Returns the OWNER-control encrypted compartment. + #[must_use] + pub const fn owner_control(&self) -> &WalletEncryptedCompartmentV1 { + return &self.owner_control; + } + + /// Returns the protected metadata encrypted compartment. + #[must_use] + pub const fn metadata(&self) -> &WalletEncryptedCompartmentV1 { + return &self.metadata; + } + + /// Returns the OWNER-only Solana secret encrypted compartment. + #[must_use] + pub const fn secret(&self) -> &WalletEncryptedCompartmentV1 { + return &self.secret; + } + + /// Returns the detached OWNER state signature. + #[must_use] + pub const fn state_signature(&self) -> &WalletStateSignatureV1 { + return &self.state_signature; + } + + /// Builds the deterministic binary transcript covered by the OWNER state signature. + #[must_use] + pub fn state_transcript(&self) -> std::vec::Vec { + return crate::state_transcript(self); + } + + /// Builds the deterministic AEAD AAD for the OWNER key slot. + #[must_use] + pub fn owner_slot_aad(&self) -> std::vec::Vec { + return crate::slot_aad(self, self.owner_slot()); + } + + /// Builds the deterministic AEAD AAD for the self-service VIEW slot when enabled. + #[must_use] + pub fn view_slot_aad(&self) -> std::option::Option> { + return match self.view_slot() { + std::option::Option::Some(slot) => std::option::Option::Some(crate::slot_aad(self, slot)), + std::option::Option::None => std::option::Option::None, + }; + } + + /// Builds deterministic AEAD AAD for one encrypted compartment. + #[must_use] + pub fn compartment_aad(&self, kind: WalletCompartmentKindV1) -> std::vec::Vec { + let compartment = match kind { + WalletCompartmentKindV1::OwnerControl => self.owner_control(), + WalletCompartmentKindV1::Metadata => self.metadata(), + WalletCompartmentKindV1::Secret => self.secret(), + }; + return crate::compartment_aad(self, compartment); + } +} + +impl std::fmt::Debug for KspWalletEnvelopeV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter + .debug_struct("KspWalletEnvelopeV1") + .field("format_version", &crate::KSPWALLET_FORMAT_VERSION_V1) + .field("owner_auth_public_key_bytes", &self.owner_auth_public_key.len()) + .field("view_descriptor", &self.view_descriptor) + .field("owner_slot", &self.owner_slot) + .field("view_slot", &self.view_slot) + .field("owner_control", &self.owner_control) + .field("metadata", &self.metadata) + .field("secret", &self.secret) + .field("state_signature", &self.state_signature) + .finish(); + } +} + +#[derive(serde::Deserialize)] +struct RawVersionProbe { + magic: std::string::String, + format_version: u32, +} + +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +struct RawEnvelopeV1 { + magic: std::string::String, + format_version: u32, + owner_auth_public_key: std::string::String, + view_descriptor: RawViewDescriptorV1, + key_slots: std::vec::Vec, + owner_control: RawOwnerControlV1, + metadata: RawMetadataV1, + secret: RawSecretV1, + state_signature: RawStateSignatureV1, +} + +impl RawEnvelopeV1 { + fn from_envelope(envelope: &KspWalletEnvelopeV1) -> Self { + let mut key_slots = std::vec::Vec::with_capacity(if envelope.view_slot().is_some() { 2 } else { 1 }); + key_slots.push(RawKeySlotV1::from_slot(envelope.owner_slot())); + if let std::option::Option::Some(view_slot) = envelope.view_slot() { + key_slots.push(RawKeySlotV1::from_slot(view_slot)); + } + return Self { + magic: std::string::String::from(crate::KSPWALLET_MAGIC), + format_version: crate::KSPWALLET_FORMAT_VERSION_V1, + owner_auth_public_key: encode_base64url(envelope.owner_auth_public_key()), + view_descriptor: RawViewDescriptorV1::from_descriptor(envelope.view_descriptor()), + key_slots, + owner_control: RawOwnerControlV1::from_compartment(envelope.owner_control()), + metadata: RawMetadataV1::from_compartment(envelope.metadata()), + secret: RawSecretV1::from_compartment(envelope.secret()), + state_signature: RawStateSignatureV1::from_signature(envelope.state_signature()), + }; + } +} + +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +struct RawViewDescriptorV1 { + enabled: bool, + slot_id: std::option::Option, +} + +impl RawViewDescriptorV1 { + fn from_descriptor(descriptor: &WalletViewDescriptorV1) -> Self { + let slot_id = match descriptor.slot_id() { + std::option::Option::Some(slot_id) => std::option::Option::Some(encode_base64url(slot_id)), + std::option::Option::None => std::option::Option::None, + }; + return Self { enabled: descriptor.enabled(), slot_id }; + } +} + +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +struct RawKeySlotV1 { + slot_id: std::string::String, + role: std::string::String, + kdf: RawKdfV1, + wrap: RawWrapV1, +} + +impl RawKeySlotV1 { + fn from_slot(slot: &WalletKeySlotV1) -> Self { + return Self { + slot_id: encode_base64url(slot.slot_id()), + role: std::string::String::from(slot.role().as_str()), + kdf: RawKdfV1::from_kdf(slot.kdf()), + wrap: RawWrapV1::from_wrap(slot.wrap()), + }; + } +} + +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +struct RawKdfV1 { + algorithm: std::string::String, + version: u32, + memory_kib: u32, + iterations: u32, + parallelism: u32, + salt: std::string::String, +} + +impl RawKdfV1 { + fn from_kdf(kdf: &WalletKdfParametersV1) -> Self { + return Self { + algorithm: std::string::String::from(kdf.algorithm().as_str()), + version: kdf.version(), + memory_kib: kdf.memory_kib(), + iterations: kdf.iterations(), + parallelism: kdf.parallelism(), + salt: encode_base64url(kdf.salt()), + }; + } +} + +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +struct RawWrapV1 { + algorithm: std::string::String, + nonce: std::string::String, + ciphertext: std::string::String, +} + +impl RawWrapV1 { + fn from_wrap(wrap: &WalletKeyWrapV1) -> Self { + return Self { + algorithm: std::string::String::from(wrap.algorithm().as_str()), + nonce: encode_base64url(wrap.nonce()), + ciphertext: encode_base64url(wrap.ciphertext()), + }; + } +} + +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +struct RawOwnerControlV1 { + control_version: u32, + algorithm: std::string::String, + nonce: std::string::String, + ciphertext: std::string::String, +} + +impl RawOwnerControlV1 { + fn from_compartment(compartment: &WalletEncryptedCompartmentV1) -> Self { + return Self { + control_version: compartment.payload_version(), + algorithm: std::string::String::from(compartment.algorithm().as_str()), + nonce: encode_base64url(compartment.nonce()), + ciphertext: encode_base64url(compartment.ciphertext()), + }; + } +} + +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +struct RawMetadataV1 { + metadata_version: u32, + algorithm: std::string::String, + nonce: std::string::String, + ciphertext: std::string::String, +} + +impl RawMetadataV1 { + fn from_compartment(compartment: &WalletEncryptedCompartmentV1) -> Self { + return Self { + metadata_version: compartment.payload_version(), + algorithm: std::string::String::from(compartment.algorithm().as_str()), + nonce: encode_base64url(compartment.nonce()), + ciphertext: encode_base64url(compartment.ciphertext()), + }; + } +} + +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +struct RawSecretV1 { + secret_version: u32, + algorithm: std::string::String, + nonce: std::string::String, + ciphertext: std::string::String, +} + +impl RawSecretV1 { + fn from_compartment(compartment: &WalletEncryptedCompartmentV1) -> Self { + return Self { + secret_version: compartment.payload_version(), + algorithm: std::string::String::from(compartment.algorithm().as_str()), + nonce: encode_base64url(compartment.nonce()), + ciphertext: encode_base64url(compartment.ciphertext()), + }; + } +} + +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +struct RawStateSignatureV1 { + algorithm: std::string::String, + signature: std::string::String, +} + +impl RawStateSignatureV1 { + fn from_signature(signature: &WalletStateSignatureV1) -> Self { + return Self { + algorithm: std::string::String::from(signature.algorithm().as_str()), + signature: encode_base64url(signature.signature()), + }; + } +} + +fn parse_raw_envelope(raw: RawEnvelopeV1) -> ksp_core_lib::Result { + if raw.magic != crate::KSPWALLET_MAGIC { + return std::result::Result::Err(format_error("Wallet magic is invalid", "magic")); + } + if raw.format_version != crate::KSPWALLET_FORMAT_VERSION_V1 { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_FORMAT_VERSION_UNSUPPORTED, "Wallet format version is not supported") + .with_context("format_version", raw.format_version.to_string()), + ); + } + + let owner_auth_public_key = + match decode_fixed::<{ crate::KSPWALLET_V1_ED25519_PUBLIC_KEY_BYTES }>(raw.owner_auth_public_key.as_str(), "owner_auth_public_key") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let view_descriptor = match parse_view_descriptor(raw.view_descriptor) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + + if raw.key_slots.is_empty() || raw.key_slots.len() > crate::KSPWALLET_V1_MAX_KEY_SLOTS { + return std::result::Result::Err(format_error("Wallet key_slots count is invalid for V1", "key_slots")); + } + let mut owner_slot = std::option::Option::None; + let mut view_slot = std::option::Option::None; + for raw_slot in raw.key_slots { + let slot = match parse_key_slot(raw_slot) { + std::result::Result::Ok(slot) => slot, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + match slot.role() { + WalletKeySlotRoleV1::Owner => { + if owner_slot.is_some() { + return std::result::Result::Err(format_error("Wallet contains duplicate OWNER key slots", "key_slots")); + } + owner_slot = std::option::Option::Some(slot); + }, + WalletKeySlotRoleV1::View => { + if view_slot.is_some() { + return std::result::Result::Err(format_error("Wallet contains duplicate VIEW key slots", "key_slots")); + } + view_slot = std::option::Option::Some(slot); + }, + } + } + let owner_slot = match owner_slot { + std::option::Option::Some(slot) => slot, + std::option::Option::None => return std::result::Result::Err(format_error("Wallet OWNER key slot is missing", "key_slots")), + }; + if let std::option::Option::Some(view_slot_ref) = view_slot.as_ref() { + if owner_slot.slot_id() == view_slot_ref.slot_id() { + return std::result::Result::Err(format_error("Wallet key slot identifiers must be unique", "key_slots")); + } + } + if view_descriptor.enabled() { + let descriptor_slot_id = match view_descriptor.slot_id() { + std::option::Option::Some(slot_id) => slot_id, + std::option::Option::None => return std::result::Result::Err(format_error("Enabled VIEW descriptor is missing slot_id", "view_descriptor")), + }; + let parsed_view_slot = match view_slot.as_ref() { + std::option::Option::Some(slot) => slot, + std::option::Option::None => return std::result::Result::Err(format_error("Enabled VIEW descriptor requires one VIEW slot", "key_slots")), + }; + if descriptor_slot_id != parsed_view_slot.slot_id() { + return std::result::Result::Err(format_error("VIEW descriptor slot_id does not match the VIEW key slot", "view_descriptor")); + } + } else if view_slot.is_some() { + return std::result::Result::Err(format_error("Disabled VIEW descriptor forbids a VIEW key slot", "key_slots")); + } + + let owner_control = match parse_compartment( + WalletCompartmentKindV1::OwnerControl, + raw.owner_control.control_version, + raw.owner_control.algorithm, + raw.owner_control.nonce, + raw.owner_control.ciphertext, + crate::KSPWALLET_V1_MAX_OWNER_CONTROL_CIPHERTEXT_BYTES, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let metadata = match parse_compartment( + WalletCompartmentKindV1::Metadata, + raw.metadata.metadata_version, + raw.metadata.algorithm, + raw.metadata.nonce, + raw.metadata.ciphertext, + crate::KSPWALLET_V1_MAX_METADATA_CIPHERTEXT_BYTES, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let secret = match parse_compartment( + WalletCompartmentKindV1::Secret, + raw.secret.secret_version, + raw.secret.algorithm, + raw.secret.nonce, + raw.secret.ciphertext, + crate::KSPWALLET_V1_MAX_SECRET_CIPHERTEXT_BYTES, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let state_signature = match parse_state_signature(raw.state_signature) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + + return std::result::Result::Ok(KspWalletEnvelopeV1 { + owner_auth_public_key, + view_descriptor, + owner_slot, + view_slot, + owner_control, + metadata, + secret, + state_signature, + }); +} + +fn parse_view_descriptor(raw: RawViewDescriptorV1) -> ksp_core_lib::Result { + if raw.enabled { + let encoded_slot_id = match raw.slot_id { + std::option::Option::Some(slot_id) => slot_id, + std::option::Option::None => return std::result::Result::Err(format_error("Enabled VIEW descriptor requires slot_id", "view_descriptor.slot_id")), + }; + let slot_id = match decode_fixed::<{ crate::KSPWALLET_V1_SLOT_ID_BYTES }>(encoded_slot_id.as_str(), "view_descriptor.slot_id") { + std::result::Result::Ok(slot_id) => slot_id, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(WalletViewDescriptorV1 { enabled: true, slot_id: std::option::Option::Some(slot_id) }); + } + if raw.slot_id.is_some() { + return std::result::Result::Err(format_error("Disabled VIEW descriptor requires null slot_id", "view_descriptor.slot_id")); + } + return std::result::Result::Ok(WalletViewDescriptorV1 { enabled: false, slot_id: std::option::Option::None }); +} + +fn parse_key_slot(raw: RawKeySlotV1) -> ksp_core_lib::Result { + let slot_id = match decode_fixed::<{ crate::KSPWALLET_V1_SLOT_ID_BYTES }>(raw.slot_id.as_str(), "key_slots.slot_id") { + std::result::Result::Ok(slot_id) => slot_id, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let role = match raw.role.as_str() { + "owner" => WalletKeySlotRoleV1::Owner, + "view" => WalletKeySlotRoleV1::View, + _ => return std::result::Result::Err(format_error("Wallet key slot role is unsupported", "key_slots.role")), + }; + let kdf = match parse_kdf(raw.kdf) { + std::result::Result::Ok(kdf) => kdf, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let wrap = match parse_wrap(raw.wrap) { + std::result::Result::Ok(wrap) => wrap, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(WalletKeySlotV1 { slot_id, role, kdf, wrap }); +} + +fn parse_kdf(raw: RawKdfV1) -> ksp_core_lib::Result { + if raw.algorithm != "argon2id" || raw.version != crate::KSPWALLET_V1_ARGON2_VERSION { + return std::result::Result::Err(crypto_parameter_error("Wallet V1 requires Argon2id version 19", "key_slots.kdf")); + } + if raw.memory_kib == 0 || raw.memory_kib > crate::KSPWALLET_V1_MAX_ARGON2_MEMORY_KIB { + return std::result::Result::Err(crypto_parameter_error("Wallet Argon2 memory_kib is outside the V1 structural range", "key_slots.kdf.memory_kib")); + } + if raw.iterations == 0 || raw.iterations > crate::KSPWALLET_V1_MAX_ARGON2_ITERATIONS { + return std::result::Result::Err(crypto_parameter_error("Wallet Argon2 iterations is outside the V1 structural range", "key_slots.kdf.iterations")); + } + 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")); + } + 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), + }; + 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("Wallet KDF salt length is outside the V1 range", "key_slots.kdf.salt")); + } + return std::result::Result::Ok(WalletKdfParametersV1 { + algorithm: WalletKdfAlgorithmV1::Argon2id, + version: raw.version, + memory_kib: raw.memory_kib, + iterations: raw.iterations, + parallelism: raw.parallelism, + salt, + }); +} + +fn parse_wrap(raw: RawWrapV1) -> ksp_core_lib::Result { + if raw.algorithm != "xchacha20-poly1305" { + return std::result::Result::Err(crypto_parameter_error("Wallet V1 key wrapping requires XChaCha20-Poly1305", "key_slots.wrap.algorithm")); + } + let nonce = match decode_fixed::<{ crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES }>(raw.nonce.as_str(), "key_slots.wrap.nonce") { + std::result::Result::Ok(nonce) => nonce, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let ciphertext = match decode_base64url(raw.ciphertext.as_str(), "key_slots.wrap.ciphertext") { + std::result::Result::Ok(ciphertext) => ciphertext, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if ciphertext.len() < crate::KSPWALLET_V1_AEAD_TAG_BYTES || ciphertext.len() > crate::KSPWALLET_V1_MAX_KEY_WRAP_CIPHERTEXT_BYTES { + return std::result::Result::Err(format_error("Wallet key-wrap ciphertext length is invalid", "key_slots.wrap.ciphertext")); + } + return std::result::Result::Ok(WalletKeyWrapV1 { algorithm: WalletAeadAlgorithmV1::XChaCha20Poly1305, nonce, ciphertext }); +} + +fn parse_compartment( + kind: WalletCompartmentKindV1, + payload_version: u32, + algorithm: std::string::String, + encoded_nonce: std::string::String, + encoded_ciphertext: std::string::String, + max_ciphertext_bytes: usize, +) -> ksp_core_lib::Result { + if payload_version != crate::KSPWALLET_V1_INITIAL_PAYLOAD_VERSION { + return std::result::Result::Err(format_error("Wallet protected payload version is unsupported by V1", "compartment.version")); + } + if algorithm != "xchacha20-poly1305" { + return std::result::Result::Err(crypto_parameter_error("Wallet V1 compartments require XChaCha20-Poly1305", "compartment.algorithm")); + } + let nonce = match decode_fixed::<{ crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES }>(encoded_nonce.as_str(), "compartment.nonce") { + std::result::Result::Ok(nonce) => nonce, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let ciphertext = match decode_base64url(encoded_ciphertext.as_str(), "compartment.ciphertext") { + std::result::Result::Ok(ciphertext) => ciphertext, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if ciphertext.len() < crate::KSPWALLET_V1_AEAD_TAG_BYTES || ciphertext.len() > max_ciphertext_bytes { + return std::result::Result::Err(format_error("Wallet compartment ciphertext length is invalid", "compartment.ciphertext")); + } + return std::result::Result::Ok(WalletEncryptedCompartmentV1 { + kind, + payload_version, + algorithm: WalletAeadAlgorithmV1::XChaCha20Poly1305, + nonce, + ciphertext, + }); +} + +fn parse_state_signature(raw: RawStateSignatureV1) -> ksp_core_lib::Result { + if raw.algorithm != "ed25519" { + return std::result::Result::Err(crypto_parameter_error("Wallet V1 state authentication requires Ed25519", "state_signature.algorithm")); + } + let signature = match decode_fixed::<{ crate::KSPWALLET_V1_ED25519_SIGNATURE_BYTES }>(raw.signature.as_str(), "state_signature.signature") { + std::result::Result::Ok(signature) => signature, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(WalletStateSignatureV1 { algorithm: WalletStateSignatureAlgorithmV1::Ed25519, signature }); +} + +fn decode_fixed(encoded: &str, field: &'static str) -> ksp_core_lib::Result<[u8; LENGTH]> { + let decoded = match decode_base64url(encoded, field) { + std::result::Result::Ok(decoded) => decoded, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if decoded.len() != LENGTH { + return std::result::Result::Err(format_error("Wallet binary field has an invalid decoded length", field)); + } + 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(format_error("Wallet binary field cannot be converted to its fixed length", field)), + }; +} + +fn decode_base64url(encoded: &str, field: &'static str) -> ksp_core_lib::Result> { + let decoded_result = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(encoded.as_bytes()); + let decoded = match decoded_result { + std::result::Result::Ok(decoded) => decoded, + std::result::Result::Err(_) => return std::result::Result::Err(format_error("Wallet binary field is not canonical Base64url without padding", field)), + }; + let canonical = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(decoded.as_slice()); + if canonical != encoded { + return std::result::Result::Err(format_error("Wallet binary field is not canonical Base64url without padding", field)); + } + return std::result::Result::Ok(decoded); +} + +fn encode_base64url(bytes: &[u8]) -> std::string::String { + return base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); +} + +fn json_parse_error(error: serde_json::Error) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_FORMAT_INVALID, "Wallet JSON document is invalid or violates the strict V1 grammar") + .with_context("line", error.line().to_string()) + .with_context("column", error.column().to_string()); +} + +fn format_error(message: &'static str, field: &'static str) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_FORMAT_INVALID, message).with_context("field", field); +} + +fn crypto_parameter_error(message: &'static str, field: &'static str) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_CRYPTO_PARAMETERS_INVALID, message).with_context("field", field); +} + +#[cfg(test)] +#[path = "../unit_tests/wire.rs"] +mod tests; diff --git a/crates/ksp-wallet-lib/tests/dependency_boundary.rs b/crates/ksp-wallet-lib/tests/dependency_boundary.rs index 46f0f94..95771e9 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: 2 +// version: 3 //! 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("base64.workspace = true")); + assert!(manifest.contains("serde = { workspace = true, features = [\"derive\"] }")); + assert!(manifest.contains("serde_json.workspace = true")); assert!(manifest.contains("zeroize.workspace = true")); for forbidden in [ "ksp-config-lib", diff --git a/crates/ksp-wallet-lib/tests/fixtures/kspwallet_v1_wire_only.json b/crates/ksp-wallet-lib/tests/fixtures/kspwallet_v1_wire_only.json new file mode 100644 index 0000000..ac7da62 --- /dev/null +++ b/crates/ksp-wallet-lib/tests/fixtures/kspwallet_v1_wire_only.json @@ -0,0 +1,67 @@ +{ + "magic": "KSPWALLET", + "format_version": 1, + "owner_auth_public_key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8", + "view_descriptor": { + "enabled": true, + "slot_id": "ICEiIyQlJicoKSorLC0uLw" + }, + "key_slots": [ + { + "slot_id": "EBESExQVFhcYGRobHB0eHw", + "role": "owner", + "kdf": { + "algorithm": "argon2id", + "version": 19, + "memory_kib": 65536, + "iterations": 3, + "parallelism": 1, + "salt": "MDEyMzQ1Njc4OTo7PD0-Pw" + }, + "wrap": { + "algorithm": "xchacha20-poly1305", + "nonce": "UFFSU1RVVldYWVpbXF1eX2BhYmNkZWZn", + "ciphertext": "gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp8" + } + }, + { + "slot_id": "ICEiIyQlJicoKSorLC0uLw", + "role": "view", + "kdf": { + "algorithm": "argon2id", + "version": 19, + "memory_kib": 32768, + "iterations": 4, + "parallelism": 1, + "salt": "QEFCQ0RFRkdISUpLTE1OTw" + }, + "wrap": { + "algorithm": "xchacha20-poly1305", + "nonce": "aGlqa2xtbm9wcXJzdHV2d3h5ent8fX5_", + "ciphertext": "oKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr8" + } + } + ], + "owner_control": { + "control_version": 1, + "algorithm": "xchacha20-poly1305", + "nonce": "kJGSk5SVlpeYmZqbnJ2en6ChoqOkpaan", + "ciphertext": "wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t_g4eLj5OXm5-jp6uvs7e7v" + }, + "metadata": { + "metadata_version": 1, + "algorithm": "xchacha20-poly1305", + "nonce": "qKmqq6ytrq-wsbKztLW2t7i5uru8vb6_", + "ciphertext": "EBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_" + }, + "secret": { + "secret_version": 1, + "algorithm": "xchacha20-poly1305", + "nonce": "wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX", + "ciphertext": "QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1-f4CBgoOEhYaHiImKi4yNjo8" + }, + "state_signature": { + "algorithm": "ed25519", + "signature": "2Nna29zd3t_g4eLj5OXm5-jp6uvs7e7v8PHy8_T19vf4-fr7_P3-_wABAgMEBQYHCAkKCwwNDg8QERITFBUWFw" + } +} diff --git a/crates/ksp-wallet-lib/tests/public_api.rs b/crates/ksp-wallet-lib/tests/public_api.rs index 731e090..158594c 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: 1 +// version: 2 //! Public API canaries for the Wallet foundation. @@ -59,3 +59,20 @@ fn wallet_error_codes_are_available_from_crate_root() { assert_eq!(code.domain(), "wallet"); } } + +#[test] +fn strict_v1_envelope_and_transcript_are_available_from_crate_root() -> ksp_core_lib::Result<()> { + let source = include_bytes!("fixtures/kspwallet_v1_wire_only.json"); + let envelope = match ksp_wallet_lib::KspWalletEnvelopeV1::parse_json(source) { + std::result::Result::Ok(envelope) => envelope, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(envelope.format_version(), ksp_wallet_lib::KSPWALLET_FORMAT_VERSION_V1); + assert_eq!(envelope.owner_slot().role(), ksp_wallet_lib::WalletKeySlotRoleV1::Owner); + assert!(envelope.view_descriptor().enabled()); + assert!(!envelope.state_transcript().is_empty()); + assert!(!envelope.owner_slot_aad().is_empty()); + assert!(envelope.view_slot_aad().is_some()); + assert!(!envelope.compartment_aad(ksp_wallet_lib::WalletCompartmentKindV1::Metadata).is_empty()); + return std::result::Result::Ok(()); +} diff --git a/crates/ksp-wallet-lib/unit_tests/transcript.rs b/crates/ksp-wallet-lib/unit_tests/transcript.rs new file mode 100644 index 0000000..87d213f --- /dev/null +++ b/crates/ksp-wallet-lib/unit_tests/transcript.rs @@ -0,0 +1,71 @@ +// file: crates/ksp-wallet-lib/unit_tests/transcript.rs +// version: 1 + +use base64::Engine as _; + +const FIXTURE: &[u8] = include_bytes!("../tests/fixtures/kspwallet_v1_wire_only.json"); + +#[test] +fn state_transcript_matches_the_frozen_v1_wire_only_canary() -> ksp_core_lib::Result<()> { + let envelope = match crate::KspWalletEnvelopeV1::parse_json(FIXTURE) { + std::result::Result::Ok(envelope) => envelope, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(envelope.state_transcript()); + assert_eq!( + encoded, + "S1NQV0FMTEVULVYxLVNUQVRFAAABAAAAAAAAAAlLU1BXQUxMRVQAAgAAAAAAAAAEAAAAAQADAAAAAAAAACAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHwAQAAAAAAAAAAEBABEAAAAAAAAABHZpZXcAEgAAAAAAAAAQICEiIyQlJicoKSorLC0uLwEAAAAAAAAAABAQERITFBUWFxgZGhscHR4fAQEAAAAAAAAABW93bmVyAQIAAAAAAAAACGFyZ29uMmlkAQMAAAAAAAAABAAAABMBBAAAAAAAAAAEAAEAAAEFAAAAAAAAAAQAAAADAQYAAAAAAAAABAAAAAEBBwAAAAAAAAAQMDEyMzQ1Njc4OTo7PD0-PwEIAAAAAAAAABJ4Y2hhY2hhMjAtcG9seTEzMDUBCQAAAAAAAAAYUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnAQoAAAAAAAAAIICBgoOEhYaHiImKi4yNjo-QkZKTlJWWl5iZmpucnZ6fAgAAAAAAAAAADW93bmVyLWNvbnRyb2wCAQAAAAAAAAAEAAAAAQICAAAAAAAAABJ4Y2hhY2hhMjAtcG9seTEzMDUCAwAAAAAAAAAYkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanAgQAAAAAAAAAMMDBwsPExcbHyMnKy8zNzs_Q0dLT1NXW19jZ2tvc3d7f4OHi4-Tl5ufo6err7O3u7wIAAAAAAAAAAAhtZXRhZGF0YQIBAAAAAAAAAAQAAAABAgIAAAAAAAAAEnhjaGFjaGEyMC1wb2x5MTMwNQIDAAAAAAAAABioqaqrrK2ur7CxsrO0tba3uLm6u7y9vr8CBAAAAAAAAAAwEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4_AgAAAAAAAAAABnNlY3JldAIBAAAAAAAAAAQAAAABAgIAAAAAAAAAEnhjaGFjaGEyMC1wb2x5MTMwNQIDAAAAAAAAABjAwcLDxMXGx8jJysvMzc7P0NHS09TV1tcCBAAAAAAAAABQQEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1-f4CBgoOEhYaHiImKi4yNjo8FAAAAAAAAAAAHZWQyNTUxOQ" + ); + return std::result::Result::Ok(()); +} + +#[test] +fn slot_aad_matches_owner_and_view_canaries() -> ksp_core_lib::Result<()> { + let envelope = match crate::KspWalletEnvelopeV1::parse_json(FIXTURE) { + std::result::Result::Ok(envelope) => envelope, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let owner = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(envelope.owner_slot_aad()); + assert_eq!( + owner, + "S1NQV0FMTEVULVYxLUFBRC1PV05FUi1TTE9UAAABAAAAAAAAAAlLU1BXQUxMRVQAAgAAAAAAAAAEAAAAAQADAAAAAAAAACAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHwEAAAAAAAAAABAQERITFBUWFxgZGhscHR4fAQEAAAAAAAAABW93bmVyAQIAAAAAAAAACGFyZ29uMmlkAQMAAAAAAAAABAAAABMBBAAAAAAAAAAEAAEAAAEFAAAAAAAAAAQAAAADAQYAAAAAAAAABAAAAAEBBwAAAAAAAAAQMDEyMzQ1Njc4OTo7PD0-PwEIAAAAAAAAABJ4Y2hhY2hhMjAtcG9seTEzMDU" + ); + let view_aad_result = envelope.view_slot_aad(); + assert!(view_aad_result.is_some()); + let view_aad = match view_aad_result { + std::option::Option::Some(view_aad) => view_aad, + std::option::Option::None => return std::result::Result::Ok(()), + }; + let view = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(view_aad); + assert_eq!( + view, + "S1NQV0FMTEVULVYxLUFBRC1WSUVXLVNMT1QAAAEAAAAAAAAACUtTUFdBTExFVAACAAAAAAAAAAQAAAABAAMAAAAAAAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fAQAAAAAAAAAAECAhIiMkJSYnKCkqKywtLi8BAQAAAAAAAAAEdmlldwECAAAAAAAAAAhhcmdvbjJpZAEDAAAAAAAAAAQAAAATAQQAAAAAAAAABAAAgAABBQAAAAAAAAAEAAAABAEGAAAAAAAAAAQAAAABAQcAAAAAAAAAEEBBQkNERUZHSElKS0xNTk8BCAAAAAAAAAASeGNoYWNoYTIwLXBvbHkxMzA1" + ); + return std::result::Result::Ok(()); +} + +#[test] +fn compartment_aad_is_domain_separated_by_kind() -> ksp_core_lib::Result<()> { + let envelope = match crate::KspWalletEnvelopeV1::parse_json(FIXTURE) { + std::result::Result::Ok(envelope) => envelope, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let owner_control = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(envelope.compartment_aad(crate::WalletCompartmentKindV1::OwnerControl)); + let metadata = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(envelope.compartment_aad(crate::WalletCompartmentKindV1::Metadata)); + let secret = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(envelope.compartment_aad(crate::WalletCompartmentKindV1::Secret)); + assert_eq!( + owner_control, + "S1NQV0FMTEVULVYxLUFBRC1PV05FUi1DT05UUk9MAAABAAAAAAAAAAlLU1BXQUxMRVQAAgAAAAAAAAAEAAAAAQADAAAAAAAAACAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHwIAAAAAAAAAAA1vd25lci1jb250cm9sAgEAAAAAAAAABAAAAAECAgAAAAAAAAASeGNoYWNoYTIwLXBvbHkxMzA1" + ); + assert_eq!( + metadata, + "S1NQV0FMTEVULVYxLUFBRC1NRVRBREFUQQAAAQAAAAAAAAAJS1NQV0FMTEVUAAIAAAAAAAAABAAAAAEAAwAAAAAAAAAgAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8CAAAAAAAAAAAIbWV0YWRhdGECAQAAAAAAAAAEAAAAAQICAAAAAAAAABJ4Y2hhY2hhMjAtcG9seTEzMDU" + ); + assert_eq!( + secret, + "S1NQV0FMTEVULVYxLUFBRC1TRUNSRVQAAAEAAAAAAAAACUtTUFdBTExFVAACAAAAAAAAAAQAAAABAAMAAAAAAAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fAgAAAAAAAAAABnNlY3JldAIBAAAAAAAAAAQAAAABAgIAAAAAAAAAEnhjaGFjaGEyMC1wb2x5MTMwNQ" + ); + assert_ne!(owner_control, metadata); + assert_ne!(metadata, secret); + return std::result::Result::Ok(()); +} diff --git a/crates/ksp-wallet-lib/unit_tests/wire.rs b/crates/ksp-wallet-lib/unit_tests/wire.rs new file mode 100644 index 0000000..1cc1201 --- /dev/null +++ b/crates/ksp-wallet-lib/unit_tests/wire.rs @@ -0,0 +1,126 @@ +// file: crates/ksp-wallet-lib/unit_tests/wire.rs +// version: 1 + +const FIXTURE: &[u8] = include_bytes!("../tests/fixtures/kspwallet_v1_wire_only.json"); + +#[test] +fn strict_v1_fixture_parses_and_round_trips_semantically() -> ksp_core_lib::Result<()> { + let parsed = match super::KspWalletEnvelopeV1::parse_json(FIXTURE) { + std::result::Result::Ok(parsed) => parsed, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(parsed.format_version(), crate::KSPWALLET_FORMAT_VERSION_V1); + assert!(parsed.view_descriptor().enabled()); + assert_eq!(parsed.owner_slot().role(), super::WalletKeySlotRoleV1::Owner); + assert_eq!(parsed.view_slot().map(super::WalletKeySlotV1::role), std::option::Option::Some(super::WalletKeySlotRoleV1::View)); + assert_eq!(parsed.owner_control().payload_version(), 1); + assert_eq!(parsed.metadata().payload_version(), 1); + assert_eq!(parsed.secret().payload_version(), 1); + + let serialized = match parsed.to_json_bytes() { + std::result::Result::Ok(serialized) => serialized, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let reparsed = match super::KspWalletEnvelopeV1::parse_json(serialized.as_slice()) { + std::result::Result::Ok(reparsed) => reparsed, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(parsed, reparsed); + return std::result::Result::Ok(()); +} + +#[test] +fn unknown_format_version_is_rejected_before_v1_shape_validation() { + let source = std::string::String::from_utf8_lossy(FIXTURE).replace("\"format_version\": 1", "\"format_version\": 2"); + 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_FORMAT_VERSION_UNSUPPORTED); +} + +#[test] +fn unknown_top_level_field_is_rejected() { + let source = std::string::String::from_utf8_lossy(FIXTURE).replacen("{", "{\n \"unexpected\": true,", 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_FORMAT_INVALID); +} + +#[test] +fn padded_or_noncanonical_base64url_is_rejected() { + let source = std::string::String::from_utf8_lossy(FIXTURE) + .replace("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\"", "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=\""); + 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_FORMAT_INVALID); +} + +#[test] +fn enabled_view_descriptor_must_match_the_view_slot() { + let source = + std::string::String::from_utf8_lossy(FIXTURE).replacen("\"slot_id\": \"ICEiIyQlJicoKSorLC0uLw\"", "\"slot_id\": \"EBESExQVFhcYGRobHB0eHw\"", 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_FORMAT_INVALID); +} + +#[test] +fn zero_or_pathological_kdf_parameters_are_rejected_before_crypto() { + let zero_source = std::string::String::from_utf8_lossy(FIXTURE).replacen("\"memory_kib\": 65536", "\"memory_kib\": 0", 1); + let zero_result = super::KspWalletEnvelopeV1::parse_json(zero_source.as_bytes()); + assert!(zero_result.is_err()); + let zero_error = match zero_result { + std::result::Result::Err(error) => error, + std::result::Result::Ok(_) => return, + }; + assert_eq!(zero_error.code(), crate::ERROR_CODE_CRYPTO_PARAMETERS_INVALID); + + let high_source = std::string::String::from_utf8_lossy(FIXTURE).replacen("\"memory_kib\": 65536", "\"memory_kib\": 1048577", 1); + let high_result = super::KspWalletEnvelopeV1::parse_json(high_source.as_bytes()); + assert!(high_result.is_err()); + let high_error = match high_result { + std::result::Result::Err(error) => error, + std::result::Result::Ok(_) => return, + }; + assert_eq!(high_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]; + let result = super::KspWalletEnvelopeV1::parse_json(oversized.as_slice()); + 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_FORMAT_INVALID); +} + +#[test] +fn envelope_debug_does_not_render_ciphertext_contents() -> ksp_core_lib::Result<()> { + let parsed = match super::KspWalletEnvelopeV1::parse_json(FIXTURE) { + std::result::Result::Ok(parsed) => parsed, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let rendered = format!("{parsed:?}"); + assert!(rendered.contains("ciphertext_bytes")); + assert!(!rendered.contains("gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp8")); + assert!(!rendered.contains("wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t_g4eLj5OXm5-jp6uvs7e7v")); + return std::result::Result::Ok(()); +} diff --git a/deltas/0.2.5/pre.003.md b/deltas/0.2.5/pre.003.md new file mode 100644 index 0000000..f527a30 --- /dev/null +++ b/deltas/0.2.5/pre.003.md @@ -0,0 +1,439 @@ + + + +# Delta `0.2.5-pre.003` — wire `.kspwallet` V1 strict + transcript/AAD + spécification interop + +## Base requise + +```text +livraison : 0.2.5-pre.002-fix.002 +workspace.package.version = "0.2.5-pre.2.fix.2" +``` + +La base effective est l'archive Gitea opérateur `0.2.5-pre.002`, complétée successivement par `pre.002-fix.001` et `pre.002-fix.002`. Les validations opérateur de cette base sont toutes passées : `cargo fmt --all`, `cargo check --workspace`, `cargo clippy --workspace --all-targets` et `cargo test -p ksp-wallet-lib`. + +## Objectif + +Figer la **grammaire externe et les octets d'authentification contextuelle V1** avant toute implémentation KDF/AEAD réelle. + +Cette tranche introduit : + +```text +KspWalletEnvelopeV1 +JSON UTF-8 strict +Base64url canonique sans padding +limite fichier 1 MiB +key slots OWNER + VIEW optionnel +slot_id 16 octets +view_descriptor signé conceptuellement par OWNER +paramètres Argon2id structuraux sérialisés +compartiments owner_control / metadata / secret versionnés +state_signature Ed25519 wire +transcript OWNER déterministe TLV +AAD OWNER slot / VIEW slot +AAD owner-control / metadata / secret +fixture wire-only déterministe +spécification docs/formats/KSPWALLET_V1.md +``` + +Aucune dérivation de clé, aucun chiffrement/déchiffrement, aucune vérification Ed25519, aucune keypair Solana et aucune persistence ne sont exécutés dans `pre.003`. + +## Version Cargo + +Conformément à `VER-ID-009` : + +```text +0.2.5-pre.2.fix.2 -> 0.2.5-pre.3 +``` + +## Dépendances + +`serde` et `serde_json` étaient déjà centralisés au workspace et sont maintenant consommés par Wallet pour le codec strict. La feature `derive` appartient au manifest membre, conformément à `DEP-CARGO-003`. + +Nouvelle dépendance commune : + +```toml +base64 = { version = "^0.23" } +``` + +Le manifest Wallet consomme : + +```toml +base64.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +``` + +La génération actuelle réauditée lors de la tranche est `base64 0.23.1`. Le moteur `URL_SAFE_NO_PAD` impose l'alphabet URL-safe sans padding et rejette les trailing bits non canoniques ; Wallet effectue en plus `decode -> re-encode == input`. + +Aucune dépendance Argon2, AEAD, CSPRNG, Ed25519 ou Solana supplémentaire n'est encore ajoutée. + +## Wire V1 figé + +### Enveloppe + +Champs top-level exacts : + +```text +magic +format_version +owner_auth_public_key +view_descriptor +key_slots +owner_control +metadata +secret +state_signature +``` + +Tous les DTO serde V1 utilisent `deny_unknown_fields`. + +Le parser effectue d'abord un probe `magic + format_version` afin qu'une version future soit rejetée explicitement comme `format_version_unsupported` avant d'essayer d'imposer la forme V1. + +### Binary encoding + +Tous les champs binaires V1 sont Base64url sans padding. Longueurs fixes principales : + +```text +owner_auth_public_key 32 octets +slot_id 16 octets +XChaCha nonce 24 octets +Ed25519 state signature 64 octets +KDF salt 16..64 octets +``` + +### Key slots + +V1 accepte exactement : + +```text +1 OWNER +0 ou 1 VIEW +maximum 2 slots +slot_id uniques +``` + +`view_descriptor` est figé comme : + +```text +enabled=true + slot_id 16 octets correspondant au slot VIEW +enabled=false + slot_id=null + aucun slot VIEW +``` + +L'ordre JSON du tableau `key_slots` n'est pas sémantique. Le serializer KSP émet OWNER puis VIEW. + +### KDF structural bounds + +Sans exécuter Argon2, le parser rejette avant crypto coûteuse : + +```text +algorithm = argon2id +version = 19 +memory_kib = 1..1_048_576 +iterations = 1..64 +parallelism = 1..64 +salt = 16..64 octets +``` + +Ces plafonds sont des limites de format/rejet hostile. Ils ne fixent **pas** les defaults de création, benchmarkés en `pre.004`. + +### Compartiments + +Versions indépendantes initiales : + +```text +control_version = 1 +metadata_version = 1 +secret_version = 1 +``` + +Tous utilisent le wire `xchacha20-poly1305` avec nonce 24 octets. Limites ciphertext : + +```text +owner_control <= 4096 octets +metadata <= 65552 octets +secret <= 4096 octets +``` + +## Transcript OWNER + +La signature ne porte jamais sur les octets JSON bruts. + +Codec binaire figé : + +```text +ASCII(domain) || 0x00 +puis pour chaque champ : + tag u16 big-endian + length u64 big-endian + value length octets + +u32 => 4 octets big-endian dans value +bool => 00 ou 01 +``` + +Domain : + +```text +KSPWALLET-V1-STATE +``` + +Le transcript inclut : + +```text +magic/version +autorité publique OWNER +descripteur VIEW stable +slot OWNER complet +owner_control complet +metadata complète +secret complet +algorithme state signature +``` + +Il exclut volontairement : + +```text +KDF/salt VIEW +wrap nonce/ciphertext VIEW +state_signature.signature +``` + +Cela matérialise le contrat décidé : VIEW peut rewrapper son accès metadata pour changer son propre password sans posséder l'autorité OWNER, mais ne peut pas modifier l'état OWNER-controlled. + +## AAD + +Domains distincts : + +```text +KSPWALLET-V1-AAD-OWNER-SLOT +KSPWALLET-V1-AAD-VIEW-SLOT +KSPWALLET-V1-AAD-OWNER-CONTROL +KSPWALLET-V1-AAD-METADATA +KSPWALLET-V1-AAD-SECRET +``` + +Les AAD utilisent le même TLV déterministe et lient au minimum magic/version, `owner_auth_public_key`, rôle/kind et paramètres publics pertinents. + +Le nonce est fourni séparément à l'AEAD et n'est pas dupliqué dans l'AAD ; le ciphertext/tag est le résultat de l'opération et n'appartient pas à son propre AAD. + +## Fixture structurelle + +Ajout : + +```text +crates/ksp-wallet-lib/tests/fixtures/kspwallet_v1_wire_only.json +``` + +Cette fixture : + +```text +est strictement test-only +n'utilise aucune clé réelle +contient des ciphertexts artificiels +contient une signature artificielle +n'est pas un wallet cryptographiquement valide +ne fixe pas les defaults Argon2 de production +``` + +Elle fixe en revanche les octets attendus du transcript et des cinq AAD. + +## API publique + +La façade réexporte les contrats V1 sans exposer les modules internes : + +```text +KspWalletEnvelopeV1 +WalletKeySlotV1 +WalletKeySlotRoleV1 +WalletKdfParametersV1 +WalletKeyWrapV1 +WalletViewDescriptorV1 +WalletEncryptedCompartmentV1 +WalletCompartmentKindV1 +WalletStateSignatureV1 +identifiants d'algorithmes V1 +constantes de limites/domain separation +``` + +`KspWalletEnvelopeV1` fournit : + +```text +parse_json(...) +to_json_bytes() +getters wire read-only +state_transcript() +owner_slot_aad() +view_slot_aad() +compartment_aad(...) +``` + +Aucun constructeur public ne permet de fabriquer arbitrairement un envelope non validé. + +Les `Debug` des enveloppes/slots/compartiments n'affichent pas les ciphertexts, salts ou nonces complets ; ils n'exposent que les algorithmes, versions et tailles nécessaires au diagnostic. + +## Documentation de format + +Nouveau contrat durable : + +```text +docs/formats/ +``` + +`docs/rules/FILE_CONTRACTS.md` est mis à jour dans la même tranche, avant que cette famille devienne une convention répétée. + +Ajouts : + +```text +docs/formats/000-README.md +docs/formats/KSPWALLET_V1.md +``` + +`KSPWALLET_V1.md` est indépendant du code Rust et décrit déjà de manière normative : grammaire, encodages, limites, strictness, key slots, tags TLV, ordre transcript et AAD. Les opérations KDF/AEAD/payloads et vecteurs crypto complets seront ajoutés dans les tranches qui les implémentent. + +## Frontières confirmées + +Toujours interdit : + +```text +Wallet -> Config +Wallet -> Transport +Wallet -> ExecutionPolicy +Wallet -> Store +Wallet -> Tauri +Wallet -> tracing direct +Wallet -> solana-pubkey direct +Wallet -> environnement processus +``` + +`Pubkey` reste possédée/réexportée par `ksp-core-lib`; `pre.003` n'introduit aucun type Pubkey Solana direct. + +Le target comportemental reste `TRACING_TARGET = "ksp-wallet-lib"` dans `src/constants.rs` et le parsing wire émet uniquement un `trace` non secret via `ksp-logging-lib` après validation réussie. + +## Tests ajoutés/étendus + +### Unitaires wire + +```text +fixture V1 parse + semantic round-trip +unknown format version +unknown top-level field +Base64url paddé/non canonique +mismatch view_descriptor / VIEW slot +KDF zéro/pathologique +fichier > 1 MiB +Debug sans ciphertext complet +``` + +### Unitaires transcript/AAD + +```text +state transcript exact +OWNER slot AAD exact +VIEW slot AAD exact +owner-control AAD exact +metadata AAD exact +secret AAD exact +domain separation +``` + +### Intégration + +Le canari public API vérifie que l'enveloppe, les rôles et codecs transcript/AAD sont réellement consommables depuis le crate-root. + +Le canari de dépendances est étendu à `base64`, `serde` et `serde_json` tout en conservant le firewall existant. + +## Fichiers ajoutés + +```text +crates/ksp-wallet-lib/src/transcript.rs +crates/ksp-wallet-lib/src/wire.rs +crates/ksp-wallet-lib/tests/fixtures/kspwallet_v1_wire_only.json +crates/ksp-wallet-lib/unit_tests/transcript.rs +crates/ksp-wallet-lib/unit_tests/wire.rs +docs/formats/000-README.md +docs/formats/KSPWALLET_V1.md +deltas/0.2.5/pre.003.md +``` + +## Fichiers modifiés + +```text +Cargo.toml +ROADMAP.md +crates/ksp-wallet-lib/Cargo.toml +crates/ksp-wallet-lib/src/constants.rs +crates/ksp-wallet-lib/src/lib.rs +crates/ksp-wallet-lib/tests/dependency_boundary.rs +crates/ksp-wallet-lib/tests/public_api.rs +docs/000-README.md +docs/plans/000-README.md +docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md +docs/plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md +docs/rules/FILE_CONTRACTS.md +``` + +## Fichiers supprimés + +Aucun. + +## Validations exécutées dans l'environnement de préparation + +Cargo/Rust n'est pas installé dans l'environnement de préparation. Les contrôles statiques suivants ont été exécutés : + +```text +diff exact contre la base pre.002 + fix.001 + fix.002 +scan production : aucun unwrap/expect/panic/? +scan boundaries : aucun solana_pubkey:: / tracing:: / std::env:: / Config / Transport / Tauri +scan RUST-API-004 : helpers pub(crate) transcript réexportés au crate-root +scan manifests : dépendances centralisées workspace +contrôle manuel des headers/version de fichiers +contrôle des longueurs/fixture et vecteurs transcript/AAD via implémentation indépendante Python du TLV +``` + +## Validations opérateur requises + +```bash +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-wallet-lib +``` + +Si cette tranche constitue le checkpoint Rust de clôture intermédiaire retenu, exécuter également : + +```bash +cargo test --workspace +``` + +## Décisions + +- geler le wire JSON V1 maintenant, avant la crypto réelle ; +- ne pas signer/canonicaliser les octets JSON ; +- utiliser un transcript sémantique TLV déterministe ; +- rendre l'ordre `key_slots` JSON non sémantique ; +- fixer `slot_id` à 16 octets ; +- permettre VIEW self-service uniquement via exclusion contrôlée de son wrapping du transcript OWNER ; +- sérialiser les versions de payload indépendamment ; +- rejeter les paramètres KDF structurellement pathologiques avant toute KDF ; +- ne pas ajouter Argon2/AEAD/Ed25519 avant consommation effective ; +- créer `docs/formats/` avec son contrat dans `FILE_CONTRACTS.md` dans la même tranche. + +## Questions ouvertes reportées à `pre.004` + +```text +defaults Argon2id mesurés sur machines cibles +API exacte de spawn_blocking pour KDF +crate CSPRNG exacte lors de l'implémentation +crate XChaCha20-Poly1305 exacte/feature set final +layout plaintext exact des wrapped capabilities +premiers vecteurs crypto publics valides +``` + +Ces questions ne doivent pas modifier silencieusement le wire/transcript/AAD figés par `pre.003`. + +## Commit attendu + +```text +v0.2.5-pre.003 +``` diff --git a/docs/000-README.md b/docs/000-README.md index 7b5a59c..d16bc37 100644 --- a/docs/000-README.md +++ b/docs/000-README.md @@ -1,5 +1,5 @@ - + # Documentation KSP @@ -9,7 +9,7 @@ Le préfixe `000-` est volontaire : la documentation est destinée à devenir vo ## Rôle de `docs/` -Le répertoire contient la documentation durable du projet : règles détaillées, idées à explorer, architecture, références, décisions, guides, plans et validations. +Le répertoire contient la documentation durable du projet : règles détaillées, idées à explorer, architecture, spécifications de formats, références, décisions, guides, plans et validations. Les documents temporaires d'une livraison ne sont pas stockés sous `docs/`. Ils sont enregistrés sous `deltas/` afin de conserver un seul historique de livraison pour l'ensemble du dépôt. @@ -31,6 +31,9 @@ docs/ │ ├── 008-DATA_MATERIALIZATION_AND_STORE.md │ ├── 009-ACQUISITION_WORKERS_AND_JOBS.md │ └── 010-APPS_SERVICES_SCENARIOS_AND_CONTROL.md +├── formats/ +│ ├── 000-README.md +│ └── KSPWALLET_V1.md ├── plans/ │ ├── 000-README.md │ ├── 001-V0_0_3_PLAN.md @@ -72,6 +75,10 @@ D'autres sous-répertoires seront ajoutés uniquement lorsque leur rôle aura é Le plan historique de la phase fondatrice clôturée est conservé dans [`plans/001-V0_0_3_PLAN.md`](plans/001-V0_0_3_PLAN.md). La séquence active des premières releases fonctionnelles est définie dans [`plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md`](plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md). Le plan détaillé de la release stable `0.1.1` est conservé comme historique clôturé dans [`plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md`](plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.2` est conservé comme historique clôturé dans [`plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`](plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.3 — Configuration foundation` est conservé comme historique clôturé dans [`plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.4 — ksp-app-config-desk` est conservé comme historique clôturé dans [`plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md`](plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md), avec sa matrice finale [`validation/001-V0_1_4_CONFIG_DESKTOP.md`](validation/001-V0_1_4_CONFIG_DESKTOP.md). Son prompt d'ouverture historique reste [`../prompts/004-V0_1_4_START_PROMPT.md`](../prompts/004-V0_1_4_START_PROMPT.md). La release stable `0.2.0` clôt l'audit de bot3 et le découpage de la série. Son plan directeur est conservé comme historique clôturé dans [`plans/007-V0_2_0_SERIES_PLANNING.md`](plans/007-V0_2_0_SERIES_PLANNING.md), avec sa matrice finale [`validation/002-V0_2_0_SERIES_PLANNING.md`](validation/002-V0_2_0_SERIES_PLANNING.md). La release stable `0.2.1 — HTTP Solana foundation` a été ouverte par [`../prompts/006-V0_2_1_START_PROMPT.md`](../prompts/006-V0_2_1_START_PROMPT.md). Son gate de sizing et sa matrice exhaustive sont conservés dans [`plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md`](plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md), avec la validation finale [`validation/003-V0_2_1_ONCHAIN_HTTP.md`](validation/003-V0_2_1_ONCHAIN_HTTP.md), README/USAGE Transport et le smoke Devnet opt-in de composition Config -> Transport. Le prompt [`../prompts/007-V0_2_2_START_PROMPT.md`](../prompts/007-V0_2_2_START_PROMPT.md) a ouvert la release stable `0.2.2 — HTTP Accounts + Tokens + Cluster`. Son plan clôturé [`plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md`](plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md) conserve l'audit et l'implémentation des 22 wrappers typés, tandis que [`validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md`](validation/004-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER.md) enregistre les validations déterministes, les graphes Cargo et les deux smokes Devnet passés avant publication. Le prompt [`../prompts/008-V0_2_3_START_PROMPT.md`](../prompts/008-V0_2_3_START_PROMPT.md) a ouvert la release stable `0.2.3 — HTTP Transactions`. Son plan clôturé [`plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md`](plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md) conserve l'audit et l'implémentation des 11 wrappers ; le réaudit [`validation/005-V0_2_3_KSP_TRANSPORT_007_RETRO_AUDIT.md`](validation/005-V0_2_3_KSP_TRANSPORT_007_RETRO_AUDIT.md) confirme la complétude des 37 wrappers HTTP typés et [`validation/006-V0_2_3_HTTP_TRANSACTIONS.md`](validation/006-V0_2_3_HTTP_TRANSACTIONS.md) enregistre les validations finales, graphes Cargo et deux smokes Devnet passés avant publication. Le prompt [`../prompts/009-V0_2_4_START_PROMPT.md`](../prompts/009-V0_2_4_START_PROMPT.md) a ouvert la release stable `0.2.4 — HTTP Blocks + Economics + compliance HTTP finale`. Son plan clôturé [`plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md`](plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md) conserve l’implémentation des 15 wrappers et la compliance `52/52 + 14/14`; la matrice finale [`validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md`](validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md) enregistre le réaudit SIMD/inventaire, les canaries globales et les preuves opérateur avant publication. Le prompt [`../prompts/010-V0_2_5_START_PROMPT.md`](../prompts/010-V0_2_5_START_PROMPT.md), finalisé par `0.2.4-pre.009-fix.001`, ouvre `0.2.5 — Wallet foundation` sur la base stable `v0.2.4`. Son gate `0.2.5-pre.001` est conservé dans [`plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md`](plans/012-V0_2_5_WALLET_FOUNDATION_PLAN.md) : il réaudite bot2/bot3 et les crates actuelles, formalise le threat model offline, retient les capacités VIEW/OWNER indépendantes et le niveau B read-only, cadre le format interopérable `.kspwallet` V1 et redimensionne la release avant implémentation cryptographique. +## Spécifications de formats + +Les formats durables, interopérables et destinés à être réimplémentables hors de KSP sont indexés depuis [`formats/000-README.md`](formats/000-README.md). Le premier format natif publié dans cette famille est [`.kspwallet` V1](formats/KSPWALLET_V1.md), dont `0.2.5-pre.003` fige l'enveloppe JSON stricte, les key slots, les limites structurelles, le transcript OWNER et les AAD indépendamment de l'implémentation Rust. + `IDEAS.md` conserve les pistes et questions qui ne sont pas encore des engagements du roadmap ni des décisions architecturales. ## Prompts diff --git a/docs/formats/000-README.md b/docs/formats/000-README.md new file mode 100644 index 0000000..7170c2c --- /dev/null +++ b/docs/formats/000-README.md @@ -0,0 +1,12 @@ + + + +# Formats KSP + +Ce répertoire contient les spécifications de formats durables appartenant à KSP et destinées à être implémentables indépendamment du code source Rust. + +Une spécification de format décrit le wire exact, les encodages, les limites, les règles de parsing/rejet, les données authentifiées et les procédures d'interopérabilité nécessaires. Elle ne dépend pas d'un type Rust interne comme condition de compréhension. + +## 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. diff --git a/docs/formats/KSPWALLET_V1.md b/docs/formats/KSPWALLET_V1.md new file mode 100644 index 0000000..faf16e7 --- /dev/null +++ b/docs/formats/KSPWALLET_V1.md @@ -0,0 +1,617 @@ + + + +# `.kspwallet` V1 — spécification du format natif Wallet KSP + +## 1. Statut et objectif + +Ce document est l'autorité normative du **wire `.kspwallet` `format_version = 1`**. + +`0.2.5-pre.003` fige : + +```text +enveloppe JSON UTF-8 stricte +encodage Base64url sans padding +key slots OWNER / VIEW +limites structurelles V1 +transcript binaire OWNER +AAD de wrapping OWNER / VIEW +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`. + +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`. + +## 2. Modèle de confiance V1 + +V1 est **autonome**. Ouvrir un wallet ne requiert aucun : + +```text +salt externe +pepper KSP +OTP +secret compilé dans KSP +service distant +réseau +keychain OS +fichier secret annexe +ancre de confiance externe +``` + +Tous les salts, nonces, paramètres KDF, wrapped keys, ciphertexts et éléments nécessaires à l'interprétation cryptographique sont dans le fichier. + +Sans password OWNER, un détenteur du fichier ne doit pas obtenir la keypair Solana, signer, exporter le secret ni produire une modification OWNER-authentifiée des metadata. VIEW donne uniquement accès à Pubkey/alias/notes et à la rotation de **son propre password VIEW**. + +Les ACL et permissions du système de fichiers sont hors du contrat cryptographique de `.kspwallet`. Remplacer entièrement un fichier par un autre wallet valide ne révèle pas l'ancien secret et revient à substituer une autre identité Wallet. + +## 3. Encodage général + +Le document est : + +```text +JSON +UTF-8 +objet top-level unique +maximum 1 048 576 octets +trailing whitespace JSON autorisé +trailing data non-whitespace interdit +``` + +L'ordre des propriétés JSON n'est **pas sémantique**. Une implémentation peut réordonner ou réindenter le JSON sans changer le transcript cryptographique tant que l'état sémantique parsé est identique. + +Tous les champs binaires utilisent **Base64url RFC 4648 alphabet URL-safe, sans padding** : + +```text +alphabet : A-Z a-z 0-9 - _ +padding = interdit +trailing bits non canoniques = interdits +``` + +Le parsing normatif effectue conceptuellement : + +```text +decode Base64url sans padding +-> re-encode Base64url sans padding +-> la chaîne obtenue doit être exactement égale à l'entrée +``` + +## 4. Magic et version + +Valeurs V1 : + +```text +magic = "KSPWALLET" +format_version = 1 +``` + +Un `magic` différent est invalide. + +Un `format_version` différent doit être signalé comme **version non supportée**, et non interprété avec la grammaire V1. + +`format_version` ne change pas lors d'une rotation de password, d'une modification d'alias/note ou d'une réécriture atomique. + +## 5. Enveloppe JSON exacte + +La forme V1 est : + +```json +{ + "magic": "KSPWALLET", + "format_version": 1, + "owner_auth_public_key": "<32 octets Base64url>", + "view_descriptor": { + "enabled": true, + "slot_id": "<16 octets Base64url ou null>" + }, + "key_slots": [ + { + "slot_id": "<16 octets Base64url>", + "role": "owner", + "kdf": { + "algorithm": "argon2id", + "version": 19, + "memory_kib": 65536, + "iterations": 3, + "parallelism": 1, + "salt": "<16..64 octets Base64url>" + }, + "wrap": { + "algorithm": "xchacha20-poly1305", + "nonce": "<24 octets Base64url>", + "ciphertext": "" + } + }, + { + "slot_id": "<16 octets Base64url>", + "role": "view", + "kdf": { + "algorithm": "argon2id", + "version": 19, + "memory_kib": 32768, + "iterations": 4, + "parallelism": 1, + "salt": "<16..64 octets Base64url>" + }, + "wrap": { + "algorithm": "xchacha20-poly1305", + "nonce": "<24 octets Base64url>", + "ciphertext": "" + } + } + ], + "owner_control": { + "control_version": 1, + "algorithm": "xchacha20-poly1305", + "nonce": "<24 octets Base64url>", + "ciphertext": "" + }, + "metadata": { + "metadata_version": 1, + "algorithm": "xchacha20-poly1305", + "nonce": "<24 octets Base64url>", + "ciphertext": "" + }, + "secret": { + "secret_version": 1, + "algorithm": "xchacha20-poly1305", + "nonce": "<24 octets Base64url>", + "ciphertext": "" + }, + "state_signature": { + "algorithm": "ed25519", + "signature": "<64 octets Base64url>" + } +} +``` + +Les valeurs Argon2 chiffrées dans cet exemple sont **des valeurs de fixture structurelle**, pas les defaults de création V1. Les defaults ne deviennent normatifs qu'après benchmark de `pre.004`. + +## 6. `owner_auth_public_key` + +`owner_auth_public_key` contient exactement **32 octets** : la clé publique Ed25519 de l'autorité d'administration du format Wallet. + +Elle est distincte de la keypair Solana. La Pubkey Solana reste dans le compartiment metadata chiffré et ne doit pas apparaître dans l'enveloppe verrouillée. + +La clé privée correspondant à `owner_auth_public_key` appartient au matériau OWNER protégé ; elle n'est jamais un champ public du fichier. + +## 7. Descripteur VIEW + +`view_descriptor` est OWNER-authentifié. + +Règles : + +```text +enabled = true => slot_id contient exactement 16 octets et un unique slot role="view" possède le même slot_id +enabled = false => slot_id est null et aucun slot role="view" n'existe +``` + +Le `slot_id` VIEW est stable pendant une rotation self-service du password VIEW. + +VIEW peut modifier uniquement les paramètres de protection de **son slot VIEW courant** dans les limites V1 et rewrapper la même capability metadata. VIEW ne peut pas modifier le descripteur signé, désactiver/recréer VIEW ou transformer son slot en OWNER. + +## 8. Key slots + +V1 accepte : + +```text +exactement 1 slot role="owner" +0 ou 1 slot role="view" +maximum total = 2 +slot_id unique entre les slots +``` + +L'ordre des entrées dans le tableau JSON `key_slots` n'est pas sémantique. Pour le transcript, OWNER est traité séparément et VIEW est représenté par son descripteur stable. + +### 8.1 KDF + +Identifiant V1 : + +```text +algorithm = "argon2id" +version = 19 +``` + +Bornes structurelles de parsing : + +```text +memory_kib : 1 .. 1 048 576 +iterations : 1 .. 64 +parallelism : 1 .. 64 +salt : 16 .. 64 octets +``` + +Ces plafonds sont des bornes de format/rejet hostile ; ils ne définissent pas les paramètres de **création par défaut**. Ceux-ci sont benchmarkés séparément. + +Le password KDF futur est la séquence exacte des octets UTF-8 fournis, sans normalisation Unicode implicite, avec une longueur maximale de 1024 octets et un password vide refusé à la création. + +### 8.2 Wrapping + +Identifiant V1 : + +```text +algorithm = "xchacha20-poly1305" +nonce = 24 octets +ciphertext >= 16 octets +ciphertext <= 4096 octets +``` + +Le ciphertext contient le tag Poly1305 de 16 octets produit par l'AEAD. + +Le contenu plaintext exact des wrapped capabilities est figé avec la couche crypto/payload suivante ; la grammaire envelope/key-slot et son AAD sont déjà figés ici. + +## 9. Compartiments chiffrés + +Trois compartiments indépendants existent : + +```text +owner_control +metadata +secret +``` + +Leurs versions initiales sont indépendantes : + +```text +control_version = 1 +metadata_version = 1 +secret_version = 1 +``` + +Les trois utilisent : + +```text +algorithm = "xchacha20-poly1305" +nonce = 24 octets +``` + +Bornes ciphertext V1 : + +```text +owner_control : 16 .. 4096 octets +metadata : 16 .. 65 552 octets +secret : 16 .. 4096 octets +``` + +La metadata plaintext V1 reste bornée à 65 536 octets. Les payloads plaintext exacts sont consolidés dans la tranche dédiée, mais les champs wire ci-dessus ne changent pas. + +Le compartiment metadata contient à terme au minimum : + +```text +Pubkey Solana Base58 canonique +alias optionnel <= 256 octets UTF-8 +maximum 64 notes +texte note <= 8192 octets UTF-8 +id de note = 16 octets aléatoires +``` + +Le compartiment secret contient la keypair Solana exacte nécessaire à la signature OWNER. + +## 10. Signature d'état OWNER + +V1 : + +```text +algorithm = "ed25519" +signature = 64 octets +``` + +La signature porte sur le **transcript sémantique OWNER-controlled**, jamais sur les octets JSON bruts. + +`state_signature.signature` elle-même est exclue du transcript. L'identifiant `algorithm = "ed25519"` est inclus. + +Les paramètres/salt/nonce/ciphertext du slot VIEW self-service sont exclus de la signature OWNER ; le descripteur stable VIEW est inclus. + +## 11. Codec binaire transcript/AAD + +### 11.1 Préfixe de domaine + +Chaque transcript/AAD commence par : + +```text +ASCII(domain_separator) || 0x00 +``` + +### 11.2 Champ TLV + +Chaque champ suivant utilise exactement : + +```text +tag : u16 big-endian +length : u64 big-endian +value : `length` octets +``` + +Un entier `u32` est encodé dans `value` comme **4 octets big-endian**. + +Un booléen est encodé comme un octet : + +```text +false = 0x00 +true = 0x01 +``` + +Les champs binaires sont les **octets décodés** du Base64url, jamais le texte Base64url. + +### 11.3 Tags V1 + +| Tag hex | Champ | +|--------:|----------------------------------------------------| +| `0001` | magic | +| `0002` | format_version | +| `0003` | owner_auth_public_key | +| `0010` | view enabled | +| `0011` | rôle VIEW littéral `view` | +| `0012` | view slot_id ; longueur zéro lorsque VIEW disabled | +| `0100` | slot_id | +| `0101` | slot role | +| `0102` | KDF algorithm | +| `0103` | KDF version | +| `0104` | KDF memory_kib | +| `0105` | KDF iterations | +| `0106` | KDF parallelism | +| `0107` | KDF salt | +| `0108` | wrap algorithm | +| `0109` | wrap nonce | +| `010A` | wrap ciphertext | +| `0200` | compartment kind | +| `0201` | compartment payload version | +| `0202` | compartment algorithm | +| `0203` | compartment nonce | +| `0204` | compartment ciphertext | +| `0500` | state-signature algorithm | + +Les tags ne remplacent pas l'ordre normatif ; **l'ordre ci-dessous est obligatoire**. + +## 12. Transcript OWNER state signature + +Domain separator : + +```text +KSPWALLET-V1-STATE +``` + +Ordre exact : + +```text +0001 magic = ASCII "KSPWALLET" +0002 format_version = u32 BE 1 +0003 owner_auth_public_key = 32 octets + +0010 view enabled = 00/01 +0011 ASCII "view" +0012 view slot_id = 16 octets si enabled, longueur 0 sinon + +OWNER slot uniquement : +0100 owner slot_id +0101 ASCII "owner" +0102 ASCII "argon2id" +0103 Argon2 version u32 BE +0104 memory_kib u32 BE +0105 iterations u32 BE +0106 parallelism u32 BE +0107 owner salt +0108 ASCII "xchacha20-poly1305" +0109 owner wrap nonce +010A owner wrap ciphertext + +owner_control : +0200 ASCII "owner-control" +0201 control_version u32 BE +0202 ASCII "xchacha20-poly1305" +0203 nonce +0204 ciphertext + +metadata : +0200 ASCII "metadata" +0201 metadata_version u32 BE +0202 ASCII "xchacha20-poly1305" +0203 nonce +0204 ciphertext + +secret : +0200 ASCII "secret" +0201 secret_version u32 BE +0202 ASCII "xchacha20-poly1305" +0203 nonce +0204 ciphertext + +0500 ASCII "ed25519" +``` + +Sont explicitement **exclus** du transcript OWNER : + +```text +VIEW KDF parameters +VIEW salt +VIEW wrap nonce +VIEW wrap ciphertext +state_signature.signature +``` + +Cette exclusion autorise VIEW à changer son propre password en rewrappant sa capability sans posséder l'autorité OWNER. Elle ne lui permet pas de modifier metadata/secret/OWNER state, qui restent signés. + +## 13. AAD des key slots + +### 13.1 OWNER + +Domain separator : + +```text +KSPWALLET-V1-AAD-OWNER-SLOT +``` + +Ordre exact : + +```text +0001 magic +0002 format_version +0003 owner_auth_public_key +0100 slot_id +0101 "owner" +0102 "argon2id" +0103 KDF version +0104 memory_kib +0105 iterations +0106 parallelism +0107 salt +0108 "xchacha20-poly1305" +``` + +Le nonce est passé séparément à l'AEAD et n'est pas répété dans l'AAD. Le ciphertext/tag est le résultat AEAD et n'appartient pas à son propre AAD. + +### 13.2 VIEW + +Domain separator : + +```text +KSPWALLET-V1-AAD-VIEW-SLOT +``` + +La suite TLV est identique à OWNER sauf : + +```text +0101 = ASCII "view" +0100 = slot_id VIEW signé par view_descriptor +``` + +Les paramètres KDF/salt sont volontairement dans l'AAD calculé pour **l'état courant** du slot VIEW. VIEW peut les remplacer lors d'une rotation de son password, puis produire un nouveau wrapping valide de la même capability metadata. + +## 14. AAD des compartiments + +Les trois AAD commencent par les champs communs : + +```text +0001 magic +0002 format_version +0003 owner_auth_public_key +``` + +Puis : + +```text +0200 compartment kind +0201 compartment payload version +0202 "xchacha20-poly1305" +``` + +Domain separators : + +```text +owner_control : KSPWALLET-V1-AAD-OWNER-CONTROL +metadata : KSPWALLET-V1-AAD-METADATA +secret : KSPWALLET-V1-AAD-SECRET +``` + +Valeurs de `compartment kind` : + +```text +owner-control +metadata +secret +``` + +Le nonce est fourni séparément à l'AEAD et le ciphertext/tag est le résultat de l'opération ; ni l'un ni l'autre n'est dupliqué dans cet AAD. + +## 15. Parsing et rejet stricts + +V1 rejette : + +```text +magic inconnu +champ top-level inconnu +champ nested inconnu +champ requis absent +champ dupliqué JSON +role slot autre que owner/view +algorithme autre que les identifiants V1 +Argon2 version autre que 19 +KDF hors bornes structurelles +Base64url invalide, paddé ou non canonique +owner_auth_public_key != 32 octets +slot_id != 16 octets +XChaCha nonce != 24 octets +state signature != 64 octets +OWNER slot absent ou dupliqué +VIEW slot dupliqué +slot_id OWNER == slot_id VIEW +VIEW descriptor incohérent avec VIEW slot +payload version autre que 1 +ciphertext sous 16 octets ou au-dessus de sa borne V1 +document > 1 MiB +trailing data non-whitespace +``` + +La vérification de taille du document précède le parsing JSON et toute opération KDF coûteuse. + +## 16. JSON produit par KSP + +Le serializer KSP V1 émet actuellement : + +```text +JSON pretty-print +ordre stable des champs du modèle KSP +OWNER slot avant VIEW slot +newline final +``` + +Cet ordre et ce pretty-print sont un **profil de sortie KSP**, pas une canonicalisation cryptographique. Une implémentation externe conforme peut produire un autre ordre/espacement JSON si le parsing V1 aboutit au même état sémantique. + +## 17. Checksum + +Aucun checksum supplémentaire V1. + +La détection cryptographique repose sur : + +```text +AEAD des wrapped keys/compartiments +state_signature OWNER pour l'état OWNER-controlled +AAD domain-separated +``` + +Un checksum non authentifié n'ajouterait pas de garantie de sécurité utile. + +## 18. Fixture structurelle `pre.003` + +Le dépôt contient : + +```text +crates/ksp-wallet-lib/tests/fixtures/kspwallet_v1_wire_only.json +``` + +Cette fixture est **wire-only et test-only** : + +```text +ses ciphertexts sont des octets artificiels +sa signature est artificielle +elle ne constitue pas un wallet cryptographiquement valide +elle ne fixe pas les defaults Argon2 de production +elle ne contient aucune clé réelle +``` + +Elle fige néanmoins : + +```text +JSON V1 +Base64url +longueurs +slots/descripteur +transcript OWNER exact +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. + +## 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.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. diff --git a/docs/plans/000-README.md b/docs/plans/000-README.md index 260bf7e..886ab03 100644 --- a/docs/plans/000-README.md +++ b/docs/plans/000-README.md @@ -1,5 +1,5 @@ - + # Plans KSP @@ -20,7 +20,7 @@ Un plan décrit le périmètre, les décisions déjà acquises, les questions ou - [`009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md`](009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md) — plan clôturé de la release stable `0.2.2`, établi par `pre.001`, corrigé après réaudit Agave v4.2.1 puis exécuté jusqu'à `pre.007-fix.002`; il couvre les 22 wrappers Accounts/Tokens/Cluster, le smoke Transport opt-in et la préparation de `0.2.3`. - [`010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md`](010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md) — plan historique clôturé de la release stable `0.2.3 — HTTP Transactions`, ouvert par `pre.001`, exécuté jusqu'à `pre.009` puis publié par `rel.001`; il couvre les 11 méthodes, la classification `8 Read / 2 WriteSubmission / 1 Simulation`, `KSP-TRANSPORT-007`, le no-resend et la préparation de `0.2.4`. - [`011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md`](011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md) — plan historique clôturé de la release stable `0.2.4`, ouvert par `pre.001`, exécuté jusqu’à `pre.009`, complété par le fix documentaire Wallet `pre.009-fix.001` puis publié par `rel.001`; il couvre les 10 Blocks + 5 Economics et la compliance finale `52/52 + 14/14` sous `KSP-TRANSPORT-007`. -- [`012-V0_2_5_WALLET_FOUNDATION_PLAN.md`](012-V0_2_5_WALLET_FOUNDATION_PLAN.md) — plan actif de `0.2.5 — Wallet foundation`, ouvert par `pre.001`; il fixe le threat model offline, le design VIEW/OWNER par key slots, le niveau B read-only, le format `.kspwallet` V1, les primitives candidates et le sizing révisé jusqu’à `pre.010`. +- [`012-V0_2_5_WALLET_FOUNDATION_PLAN.md`](012-V0_2_5_WALLET_FOUNDATION_PLAN.md) — plan actif de `0.2.5 — Wallet foundation`, ouvert par `pre.001`; `pre.002` matérialise la crate et `pre.003` fige le wire JSON V1, ses key slots, limites, transcript/AAD et la première spécification interopérable `docs/formats/KSPWALLET_V1.md`, avant la cryptographie effective de `pre.004+`. Le `pre.001` de chaque release fonctionnelle peut introduire son propre plan détaillé lorsque la release s'ouvre. diff --git a/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md b/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md index f6cc51b..7a42f33 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 @@ -410,6 +410,8 @@ La release doit fournir `docs/formats/KSPWALLET_V1.md` comme spécification sép `0.2.5-pre.002` matérialise la crate sans ouvrir encore le codec ou la cryptographie du fichier : `WalletView`/`WalletOwner`, `WalletCapability`, projections `LockedWalletInfo`/`WalletInfo`/`WalletNote`, wrappers `ViewPassword`/`OwnerPassword`, codes d’erreur Wallet et target de logging explicite `ksp-wallet-lib`. La crate dépend seulement de Core, Logging et `zeroize`; elle consomme la Pubkey exclusivement via `ksp_core_lib::Pubkey` et ne dépend directement ni de `solana-pubkey`, ni de Config, Transport, ExecutionPolicy, Store ou Tauri. Les primitives Solana keypair/signature ne seront ajoutées que lorsqu’elles seront réellement consommées. +`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.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 3464887..cec305e 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 @@ -308,7 +308,7 @@ exactement 1 slot OWNER 0 ou 1 slot VIEW ``` -Le slot OWNER et l'état de contrôle sont OWNER-signed. Pour VIEW, l'état OWNER-signed contient un **descripteur stable** (`enabled`, rôle et identifiant de slot ou équivalent wire à figer en `pre.003`). Lorsque VIEW est activé, le fichier doit contenir exactement un slot VIEW correspondant à ce descripteur. Ses paramètres Argon2id autorisés, son salt et son nonce/ciphertext de wrapping restent rotatables sans OWNER, mais les algorithmes V1, son rôle, son identité et son activation ne le sont pas. Le wrapping VIEW utilise un AAD qui lie au minimum format/version, autorité wallet, rôle VIEW et identifiant signé du slot. +Le slot OWNER et l'état de contrôle sont OWNER-signed. Pour VIEW, l'état OWNER-signed contient un **descripteur stable** (`enabled` et `slot_id`), figé par le wire V1 de `pre.003`. Lorsque VIEW est activé, le fichier doit contenir exactement un slot VIEW correspondant à ce descripteur. Ses paramètres Argon2id autorisés, son salt et son nonce/ciphertext de wrapping restent rotatables sans OWNER, mais les algorithmes V1, son rôle, son identité et son activation ne le sont pas. Le wrapping VIEW utilise un AAD qui lie au minimum format/version, autorité wallet, rôle VIEW et identifiant signé du slot. Le wire utilise néanmoins un tableau générique `key_slots` afin qu'un futur `format_version` puisse introduire d'autres rôles sans remodeler toute l'enveloppe. @@ -461,7 +461,7 @@ Le fichier JSON n'est **jamais** signé comme octets JSON bruts et aucune canoni ### 9.2 Enveloppe conceptuelle -Structure de travail à figer par le codec `pre.003` : +Structure V1 figée par le codec `pre.003` (les longueurs/encodages exacts sont normatifs dans `docs/formats/KSPWALLET_V1.md`) : ```json { @@ -474,6 +474,7 @@ Structure de travail à figer par le codec `pre.003` : }, "key_slots": [ { + "slot_id": "", "role": "owner|view", "kdf": { "algorithm": "argon2id", @@ -491,16 +492,19 @@ Structure de travail à figer par le codec `pre.003` : } ], "owner_control": { + "control_version": 1, "algorithm": "xchacha20-poly1305", "nonce": "", "ciphertext": "" }, "metadata": { + "metadata_version": 1, "algorithm": "xchacha20-poly1305", "nonce": "", "ciphertext": "" }, "secret": { + "secret_version": 1, "algorithm": "xchacha20-poly1305", "nonce": "", "ciphertext": "" @@ -544,7 +548,7 @@ content keys password-derived keys ``` -La clé publique d'administration révèle un fingerprint aléatoire du **format wallet**, pas l'identité blockchain Solana. Elle constitue la racine de vérification **embarquée** de l'état sous la même autorité OWNER ; sa présence ne crée aucune dépendance à un facteur externe. Le placement et l'encodage exacts de `view_descriptor` restent à figer en `pre.003` ; son invariant est qu'il est OWNER-signed alors que seuls les champs de protection du slot VIEW correspondant sont self-rotatables. +La clé publique d'administration révèle un fingerprint aléatoire du **format wallet**, pas l'identité blockchain Solana. Elle constitue la racine de vérification **embarquée** de l'état sous la même autorité OWNER ; sa présence ne crée aucune dépendance à un facteur externe. `pre.003` fige `view_descriptor` avec `enabled` + `slot_id`: `slot_id` vaut exactement 16 octets Base64url lorsque VIEW est activé et `null` lorsqu'il est désactivé ; le descripteur est OWNER-signed alors que seuls les champs de protection du slot VIEW correspondant sont self-rotatables. ### 9.4 Pubkey et secret payload @@ -577,7 +581,7 @@ id = 16 octets aléatoires, Base64url sans padding data = texte UTF-8 protégé ``` -L'ID facilite update/delete sans rendre le texte lui-même identifiant. Les bornes restent vérifiables pendant `pre.003`; toute modification avant freeze wire doit être tracée. +L'ID facilite update/delete sans rendre le texte lui-même identifiant. `pre.003` fige ces bornes structurelles V1 ; les payloads plaintext exacts restent implémentés dans les tranches suivantes sans modifier ces plafonds. ### 9.6 Password text contract @@ -659,7 +663,7 @@ identifiants algo/version pertinents paramètres publics nécessaires à lier le ciphertext à son contexte ``` -Le layout exact du transcript et de chaque AAD devient normatif dans `docs/formats/KSPWALLET_V1.md` et dans les vecteurs déterministes. +`pre.003` fige le layout exact dans `docs/formats/KSPWALLET_V1.md` : domain separator ASCII terminé par `0x00`, puis champs TLV `tag u16 BE || length u64 BE || value`; les `u32` sont encodés sur 4 octets big-endian. Le transcript OWNER inclut le slot OWNER complet et le descripteur VIEW stable mais exclut KDF/salt/wrap VIEW self-service. Les AAD OWNER/VIEW et owner-control/metadata/secret utilisent des domain separators distincts. Les vecteurs déterministes de la tranche verrouillent les octets produits. ## 11. Secret en mémoire @@ -1193,4 +1197,4 @@ Une future `format_version >= 2` pourra réétudier des facteurs/ancrages extern ## 26. Suite immédiate -`0.2.5-pre.003` est la suite immédiate : codec JSON strict, limites, DTOs d’enveloppe/key slots, transcript/AAD et première spécification `docs/formats/KSPWALLET_V1.md`. La cryptographie effective KDF/AEAD reste à `pre.004+`. +`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. diff --git a/docs/rules/FILE_CONTRACTS.md b/docs/rules/FILE_CONTRACTS.md index ba3671a..b49956f 100644 --- a/docs/rules/FILE_CONTRACTS.md +++ b/docs/rules/FILE_CONTRACTS.md @@ -1,5 +1,5 @@ - + # Contrats des fichiers @@ -25,18 +25,20 @@ Les règles `FILE-*` définissent la responsabilité et le mode de modification ## Répertoire `docs/` -| Fichier/famille | Responsabilité | Règle de modification | -|-----------------------------------|----------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| -| `docs/000-README.md` | Indexer et expliquer la documentation tout en restant en tête des listings et arbres de fichiers. | Modifier lorsque l'organisation durable de `docs/` change ; `000-README.md` reste prioritaire lorsqu'un ordre numérique existe. | -| `docs/rules/*.md` | Définir les règles normatives par portée. | Modifier uniquement pour une décision normative ; incrémenter la version du fichier à chaque enregistrement modifiant son contenu. | -| `docs/rules/PROMPT_STRUCTURE.md` | Définir la structure, le cycle de vie et le dimensionnement des prompts/sessions KSP. | Modifier lorsque le contrat des prompts ou les règles de découpage de sessions/prereleases changent. | -| `docs/architecture/000-README.md` | Indexer les documents décrivant l'architecture KSP décidée ou en cours de cadrage explicite. | Modifier lorsque la structure documentaire d'architecture change. | -| `docs/architecture/*.md` | Décrire les objectifs, frontières, responsabilités et architecture courante ou explicitement proposée. | Ne pas utiliser comme journal de livraison ; distinguer clairement les décisions validées des hypothèses encore ouvertes. | -| `docs/plans/000-README.md` | Indexer les plans de versions/phases. | Modifier lorsque l'organisation des plans change. | -| `docs/plans/*.md` | Organiser une version ou phase complexe et, pour `pre.001`, détailler la prévision souple de ses prereleases. | Faire évoluer le plan lorsque la planification change ; prévoir des tranches intermédiaires bornées et redécouper toute tranche estimée trop lourde. | -| `docs/IDEAS.md` | Conserver les idées, pistes, questions et alternatives à explorer qui ne sont pas encore des engagements du roadmap. | Ajouter une idée dès qu'elle mérite d'être conservée ; mettre à jour son statut lorsqu'elle est explorée, retenue, rejetée ou transférée. | -| futurs documents de référence | Définir vocabulaire, identifiants et références canoniques. | Mettre à jour quand la référence canonique évolue. | -| futures validations | Conserver des résultats réellement exécutés. | Ne jamais enregistrer une validation supposée comme réussie. | +| Fichier/famille | Responsabilité | Règle de modification | +|-----------------------------------|-------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `docs/000-README.md` | Indexer et expliquer la documentation tout en restant en tête des listings et arbres de fichiers. | Modifier lorsque l'organisation durable de `docs/` change ; `000-README.md` reste prioritaire lorsqu'un ordre numérique existe. | +| `docs/formats/000-README.md` | Indexer les spécifications de formats durables KSP destinées à l'interopérabilité externe. | Modifier lorsqu'un format durable entre/sort de cette famille ou que son statut change ; conserver `000-README.md` comme point d'entrée. | +| `docs/formats/*.md` | Spécifier un wire KSP durable indépendamment de son implémentation, avec encodages, limites, parsing, auth et vecteurs. | Modifier avec traçabilité lorsqu'un contrat de format évolue ; après publication stable d'une version de format, toute incompatibilité de wire ouvre une nouvelle version de format plutôt qu'une tolérance silencieuse. | +| `docs/rules/*.md` | Définir les règles normatives par portée. | Modifier uniquement pour une décision normative ; incrémenter la version du fichier à chaque enregistrement modifiant son contenu. | +| `docs/rules/PROMPT_STRUCTURE.md` | Définir la structure, le cycle de vie et le dimensionnement des prompts/sessions KSP. | Modifier lorsque le contrat des prompts ou les règles de découpage de sessions/prereleases changent. | +| `docs/architecture/000-README.md` | Indexer les documents décrivant l'architecture KSP décidée ou en cours de cadrage explicite. | Modifier lorsque la structure documentaire d'architecture change. | +| `docs/architecture/*.md` | Décrire les objectifs, frontières, responsabilités et architecture courante ou explicitement proposée. | Ne pas utiliser comme journal de livraison ; distinguer clairement les décisions validées des hypothèses encore ouvertes. | +| `docs/plans/000-README.md` | Indexer les plans de versions/phases. | Modifier lorsque l'organisation des plans change. | +| `docs/plans/*.md` | Organiser une version ou phase complexe et, pour `pre.001`, détailler la prévision souple de ses prereleases. | Faire évoluer le plan lorsque la planification change ; prévoir des tranches intermédiaires bornées et redécouper toute tranche estimée trop lourde. | +| `docs/IDEAS.md` | Conserver les idées, pistes, questions et alternatives à explorer qui ne sont pas encore des engagements du roadmap. | Ajouter une idée dès qu'elle mérite d'être conservée ; mettre à jour son statut lorsqu'elle est explorée, retenue, rejetée ou transférée. | +| futurs documents de référence | Définir vocabulaire, identifiants et références canoniques. | Mettre à jour quand la référence canonique évolue. | +| futures validations | Conserver des résultats réellement exécutés. | Ne jamais enregistrer une validation supposée comme réussie. | ## Répertoire `config/`