Compare commits

..

2 Commits

58 changed files with 1064 additions and 47 deletions

13
.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
# Rust build artifacts
/target/
**/*.rs.bk
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db

506
Cargo.lock generated
View File

@ -2,6 +2,60 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 3
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "chronogrid_ai" name = "chronogrid_ai"
version = "0.1.0" version = "0.1.0"
@ -13,7 +67,459 @@ version = "0.1.0"
[[package]] [[package]]
name = "chronogrid_crypto" name = "chronogrid_crypto"
version = "0.1.0" version = "0.1.0"
dependencies = [
"argon2",
"keyring",
"rand",
"thiserror",
"zeroize",
]
[[package]] [[package]]
name = "chronogrid_net" name = "chronogrid_net"
version = "0.1.0" version = "0.1.0"
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "dbus"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e"
dependencies = [
"libc",
"libdbus-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "dbus-secret-service"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
dependencies = [
"dbus",
"zeroize",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "keyring"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"dbus-secret-service",
"log",
"security-framework 2.11.1",
"security-framework 3.7.0",
"windows-sys 0.60.2",
"zeroize",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libdbus-sys"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
dependencies = [
"pkg-config",
]
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core",
"subtle",
]
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "zerocopy"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]

View File

@ -5,4 +5,15 @@
- **Status**: Accepted - **Status**: Accepted
- **Context**: Design a multi-platform (Windows, Linux, Android, Headless) productivity engine for time, project, and note management without centralized servers. - **Context**: Design a multi-platform (Windows, Linux, Android, Headless) productivity engine for time, project, and note management without centralized servers.
- **Decision**: Adopted Rust core with SQLCipher local storage, Automerge CRDT delta sync over libp2p, and Flutter presentation layer bound via C-FFI. - **Decision**: Adopted Rust core with SQLCipher local storage, Automerge CRDT delta sync over libp2p, and Flutter presentation layer bound via C-FFI.
## [2026-08-09] - ADL-002: chronogrid_crypto Crate – Argon2id KDF & OS Keychain
- **Status**: Accepted
- **Context**: Task 1.2 required implementing the `chronogrid_crypto` crate with Argon2id master-key derivation and OS-native keychain bindings per `docs/ARCHITECTURE/02-security-and-crypto.md`.
- **Decision**:
- Argon2id parameters pinned: 64 MiB memory, 3 iterations, 4 threads, 16-byte random salt, 32-byte output.
- `DerivedKey` struct implements `Zeroize + Drop` to scrub sensitive memory on scope exit.
- OS keychain abstracted via `keyring` crate v3 (Windows Credential Manager / DPAPI, Linux SecretService / libsecret). Android keychain is excluded (`cfg(not(target_os = "android"))`) and will be handled by Flutter KeyStore bridge in Task 1.5.
- Raw key material is Base64-encoded before credential-store insertion for UTF-8 backend compatibility.
- Inline Base64 encode/decode helpers avoid adding an external Base64 dependency.

View File

@ -7,5 +7,13 @@ rust-version.workspace = true
publish = false publish = false
[dependencies] [dependencies]
argon2 = "0.5"
rand = "0.8"
thiserror = "2"
zeroize = { version = "1", features = ["derive"] }
# OS keychain support (not available on Android – handled via Flutter/NDK bridge)
[target.'cfg(not(target_os = "android"))'.dependencies]
keyring = { version = "3", features = ["sync-secret-service", "windows-native", "apple-native"] }
[dev-dependencies] [dev-dependencies]

View File

@ -0,0 +1,33 @@
//! Error types for the `chronogrid_crypto` crate.
use thiserror::Error;
/// Unified error type for all cryptographic operations.
#[derive(Debug, Error)]
pub enum CryptoError {
/// Argon2id hashing failed internally.
#[error("Argon2id key derivation failed: {0}")]
KeyDerivation(String),
/// The provided salt has an invalid length (expected 16 bytes).
#[error("invalid salt length: expected 16 bytes, got {0}")]
InvalidSaltLength(usize),
/// OS keychain operation failed.
#[error("keychain error: {0}")]
Keychain(String),
/// The requested credential was not found in the OS keychain.
#[error("credential not found in keychain for service='{service}', account='{account}'")]
CredentialNotFound {
service: String,
account: String,
},
/// A generic / unexpected internal error.
#[error("internal crypto error: {0}")]
Internal(String),
}
/// Convenience alias used throughout the crate.
pub type Result<T> = std::result::Result<T, CryptoError>;

View File

@ -0,0 +1,189 @@
//! Argon2id master-key derivation.
//!
//! Parameters are pinned to the values mandated by
//! `docs/ARCHITECTURE/02-security-and-crypto.md`:
//!
//! | Parameter | Value |
//! |-------------|---------------|
//! | Memory | 64 MiB (65 536 KiB) |
//! | Time cost | 3 iterations |
//! | Parallelism | 4 threads |
//! | Salt | 16 random bytes |
//! | Output | 32 bytes (256-bit master key) |
use argon2::{Algorithm, Argon2, Params, Version};
use rand::RngCore;
use zeroize::Zeroize;
use crate::error::{CryptoError, Result};
// ── Spec-mandated constants ──────────────────────────────────────────────────
/// Memory cost in KiB (64 MiB).
const ARGON2_MEMORY_KIB: u32 = 65_536;
/// Time cost (iterations).
const ARGON2_TIME_COST: u32 = 3;
/// Degree of parallelism.
const ARGON2_PARALLELISM: u32 = 4;
/// Length of the derived key in bytes.
pub const DERIVED_KEY_LEN: usize = 32;
/// Required salt length in bytes.
pub const SALT_LEN: usize = 16;
// ── Public types ─────────────────────────────────────────────────────────────
/// Container for a derived master key and its associated salt.
///
/// Implements [`Zeroize`] + [`Drop`] so sensitive bytes are wiped from memory
/// when the value goes out of scope.
#[derive(Zeroize)]
#[zeroize(drop)]
pub struct DerivedKey {
/// 32-byte derived master key.
key: [u8; DERIVED_KEY_LEN],
/// 16-byte salt that was used during derivation.
salt: [u8; SALT_LEN],
}
impl DerivedKey {
/// Returns the derived 32-byte key material.
#[inline]
pub fn key(&self) -> &[u8; DERIVED_KEY_LEN] {
&self.key
}
/// Returns the 16-byte salt used during derivation.
#[inline]
pub fn salt(&self) -> &[u8; SALT_LEN] {
&self.salt
}
/// Consumes self and returns the raw key bytes.
///
/// **Warning**: The caller is responsible for zeroing these bytes after use.
#[inline]
pub fn into_key_bytes(self) -> [u8; DERIVED_KEY_LEN] {
let k = self.key;
// Prevent the Drop impl from double-zeroing by forgetting self.
std::mem::forget(self);
k
}
}
impl std::fmt::Debug for DerivedKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DerivedKey")
.field("key", &"[REDACTED]")
.field("salt_len", &self.salt.len())
.finish()
}
}
// ── Core derivation function ─────────────────────────────────────────────────
/// Generates a cryptographically random 16-byte salt.
pub fn generate_salt() -> [u8; SALT_LEN] {
let mut salt = [0u8; SALT_LEN];
rand::thread_rng().fill_bytes(&mut salt);
salt
}
/// Derives a 256-bit master key from `passphrase` using Argon2id.
///
/// If `salt` is `None`, a fresh 16-byte random salt is generated.
/// If `salt` is `Some(bytes)`, the provided slice **must** be exactly 16 bytes.
///
/// # Errors
///
/// Returns [`CryptoError::InvalidSaltLength`] if a provided salt is not 16 bytes.
/// Returns [`CryptoError::KeyDerivation`] if the Argon2 hasher fails internally.
pub fn derive_master_key(passphrase: &str, salt: Option<&[u8]>) -> Result<DerivedKey> {
// Resolve or generate salt.
let salt_bytes: [u8; SALT_LEN] = match salt {
Some(s) => {
if s.len() != SALT_LEN {
return Err(CryptoError::InvalidSaltLength(s.len()));
}
let mut arr = [0u8; SALT_LEN];
arr.copy_from_slice(s);
arr
}
None => generate_salt(),
};
// Build Argon2id with spec parameters.
let params = Params::new(
ARGON2_MEMORY_KIB,
ARGON2_TIME_COST,
ARGON2_PARALLELISM,
Some(DERIVED_KEY_LEN),
)
.map_err(|e| CryptoError::KeyDerivation(e.to_string()))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
// Derive key into output buffer.
let mut key_buf = [0u8; DERIVED_KEY_LEN];
argon2
.hash_password_into(passphrase.as_bytes(), &salt_bytes, &mut key_buf)
.map_err(|e| CryptoError::KeyDerivation(e.to_string()))?;
Ok(DerivedKey {
key: key_buf,
salt: salt_bytes,
})
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derive_key_with_random_salt() {
let dk = derive_master_key("correct horse battery staple", None)
.expect("derivation should succeed");
assert_eq!(dk.key().len(), DERIVED_KEY_LEN);
assert_eq!(dk.salt().len(), SALT_LEN);
}
#[test]
fn derive_key_deterministic_with_same_salt() {
let salt = [0xABu8; SALT_LEN];
let dk1 = derive_master_key("hunter2", Some(&salt)).unwrap();
let dk2 = derive_master_key("hunter2", Some(&salt)).unwrap();
assert_eq!(dk1.key(), dk2.key(), "same passphrase + salt must yield identical key");
}
#[test]
fn different_passphrases_yield_different_keys() {
let salt = [0x01u8; SALT_LEN];
let dk1 = derive_master_key("alpha", Some(&salt)).unwrap();
let dk2 = derive_master_key("beta", Some(&salt)).unwrap();
assert_ne!(dk1.key(), dk2.key());
}
#[test]
fn reject_invalid_salt_length() {
let bad_salt = [0u8; 10];
let res = derive_master_key("pw", Some(&bad_salt));
assert!(res.is_err());
match res.unwrap_err() {
CryptoError::InvalidSaltLength(len) => assert_eq!(len, 10),
other => panic!("expected InvalidSaltLength, got: {other}"),
}
}
#[test]
fn debug_does_not_leak_key() {
let dk = derive_master_key("secret", None).unwrap();
let debug = format!("{dk:?}");
assert!(debug.contains("REDACTED"), "Debug output must redact key material");
assert!(!debug.contains("secret"));
}
}

View File

@ -0,0 +1,224 @@
//! OS keychain integration for secure credential storage.
//!
//! Abstracts platform-specific keychain backends:
//!
//! | Platform | Backend |
//! |----------|----------------------------------------------|
//! | Windows | Windows Credential Manager (DPAPI) |
//! | Linux | SecretService D-Bus API (libsecret) |
//! | macOS | macOS Keychain (Security framework) |
//! | Android | **Not supported here** — use Flutter KeyStore bridge |
//!
//! This module is compiled only on non-Android targets.
#[cfg(not(target_os = "android"))]
mod platform {
use keyring::Entry;
use crate::error::{CryptoError, Result};
/// Default service identifier used when none is provided.
pub const DEFAULT_SERVICE: &str = "com.chronogrid.p2p";
/// Default account identifier used when none is provided.
pub const DEFAULT_ACCOUNT: &str = "master_key";
/// Stores raw key material in the OS credential store.
///
/// The bytes are Base64-encoded before storage because some credential
/// backends only support UTF-8 string payloads.
///
/// # Arguments
///
/// * `service` – Logical service name (e.g. `"com.chronogrid.p2p"`).
/// * `account` – Account / label for the credential.
/// * `key_material` – Raw bytes to store (typically 32-byte master key + 16-byte salt).
pub fn store_credential(
service: &str,
account: &str,
key_material: &[u8],
) -> Result<()> {
let entry = Entry::new(service, account)
.map_err(|e| CryptoError::Keychain(e.to_string()))?;
// Encode as Base64 for credential-manager compatibility.
use base64_encode::encode;
let encoded = encode(key_material);
entry
.set_password(&encoded)
.map_err(|e| CryptoError::Keychain(e.to_string()))?;
Ok(())
}
/// Retrieves raw key material from the OS credential store.
///
/// # Errors
///
/// Returns [`CryptoError::CredentialNotFound`] when no matching entry exists.
pub fn retrieve_credential(service: &str, account: &str) -> Result<Vec<u8>> {
let entry = Entry::new(service, account)
.map_err(|e| CryptoError::Keychain(e.to_string()))?;
let encoded = entry.get_password().map_err(|e| {
// Distinguish "not found" from other failures.
let msg = e.to_string();
if msg.contains("No matching entry")
|| msg.contains("not found")
|| msg.contains("NoEntry")
{
CryptoError::CredentialNotFound {
service: service.to_owned(),
account: account.to_owned(),
}
} else {
CryptoError::Keychain(msg)
}
})?;
use base64_decode::decode;
let bytes = decode(&encoded)
.map_err(|e| CryptoError::Internal(format!("base64 decode failed: {e}")))?;
Ok(bytes)
}
/// Deletes a credential from the OS credential store.
///
/// Returns `Ok(())` even if the credential did not exist (idempotent).
pub fn delete_credential(service: &str, account: &str) -> Result<()> {
let entry = Entry::new(service, account)
.map_err(|e| CryptoError::Keychain(e.to_string()))?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(e) => {
let msg = e.to_string();
// Treat "not found" as a no-op for idempotency.
if msg.contains("No matching entry")
|| msg.contains("not found")
|| msg.contains("NoEntry")
{
Ok(())
} else {
Err(CryptoError::Keychain(msg))
}
}
}
}
// ── Inline Base64 helpers (no extra dependency needed) ────────────────
mod base64_encode {
use std::fmt::Write;
const CHARS: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
pub fn encode(input: &[u8]) -> String {
let mut out = String::with_capacity((input.len() + 2) / 3 * 4);
for chunk in input.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
let triple = (b0 << 16) | (b1 << 8) | b2;
let _ = write!(out, "{}", CHARS[((triple >> 18) & 0x3F) as usize] as char);
let _ = write!(out, "{}", CHARS[((triple >> 12) & 0x3F) as usize] as char);
if chunk.len() > 1 {
let _ = write!(out, "{}", CHARS[((triple >> 6) & 0x3F) as usize] as char);
} else {
out.push('=');
}
if chunk.len() > 2 {
let _ = write!(out, "{}", CHARS[(triple & 0x3F) as usize] as char);
} else {
out.push('=');
}
}
out
}
}
mod base64_decode {
pub fn decode(input: &str) -> std::result::Result<Vec<u8>, String> {
let input = input.trim_end_matches('=');
let mut out = Vec::with_capacity(input.len() * 3 / 4);
let val = |c: u8| -> std::result::Result<u32, String> {
match c {
b'A'..=b'Z' => Ok((c - b'A') as u32),
b'a'..=b'z' => Ok((c - b'a' + 26) as u32),
b'0'..=b'9' => Ok((c - b'0' + 52) as u32),
b'+' => Ok(62),
b'/' => Ok(63),
_ => Err(format!("invalid base64 char: {c}")),
}
};
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
let remaining = bytes.len() - i;
if remaining >= 4 {
let n = (val(bytes[i])? << 18)
| (val(bytes[i + 1])? << 12)
| (val(bytes[i + 2])? << 6)
| val(bytes[i + 3])?;
out.push((n >> 16) as u8);
out.push((n >> 8 & 0xFF) as u8);
out.push((n & 0xFF) as u8);
i += 4;
} else if remaining == 3 {
let n = (val(bytes[i])? << 18)
| (val(bytes[i + 1])? << 12)
| (val(bytes[i + 2])? << 6);
out.push((n >> 16) as u8);
out.push((n >> 8 & 0xFF) as u8);
i += 3;
} else if remaining == 2 {
let n = (val(bytes[i])? << 18) | (val(bytes[i + 1])? << 12);
out.push((n >> 16) as u8);
i += 2;
} else {
break;
}
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::base64_decode::decode;
use super::base64_encode::encode;
#[test]
fn base64_round_trip() {
let original = b"hello, chronogrid crypto!";
let encoded = encode(original);
let decoded = decode(&encoded).expect("decode should succeed");
assert_eq!(decoded, original);
}
#[test]
fn base64_round_trip_32_bytes() {
let original: Vec<u8> = (0..32).collect();
let encoded = encode(&original);
let decoded = decode(&encoded).expect("decode should succeed");
assert_eq!(decoded, original);
}
#[test]
fn base64_empty() {
assert_eq!(encode(b""), "");
assert_eq!(decode("").unwrap(), Vec::<u8>::new());
}
}
}
// Re-export platform items at crate level (non-Android only).
#[cfg(not(target_os = "android"))]
pub use platform::*;

View File

@ -1,12 +1,43 @@
//! # chronogrid_crypto //! # chronogrid_crypto
//! //!
//! Cryptographic primitives for ChronoGrid P2P. //! Cryptographic primitives for **ChronoGrid P2P**.
//! Handles Argon2id master key derivation and OS keychain bindings. //!
//! This crate provides two core capabilities:
//!
//! 1. **Argon2id master-key derivation** ([`kdf`]) — derives a 256-bit key from a
//! user passphrase using the parameters specified in
//! `docs/ARCHITECTURE/02-security-and-crypto.md`.
//!
//! 2. **OS keychain integration** ([`keychain`]) — stores and retrieves encrypted
//! key material via platform-native credential managers (Windows Credential
//! Manager / DPAPI, Linux SecretService / libsecret). Android credential
//! management is delegated to the Flutter KeyStore bridge.
//!
//! ## Quick Start
//!
//! ```rust,no_run
//! use chronogrid_crypto::{kdf, keychain};
//!
//! // Derive a master key from a passphrase
//! let derived = kdf::derive_master_key("my secret passphrase", None)
//! .expect("key derivation failed");
//!
//! // Persist the key + salt in the OS keychain
//! let mut combined = Vec::with_capacity(48);
//! combined.extend_from_slice(derived.key());
//! combined.extend_from_slice(derived.salt());
//!
//! keychain::store_credential(
//! keychain::DEFAULT_SERVICE,
//! keychain::DEFAULT_ACCOUNT,
//! &combined,
//! ).expect("keychain store failed");
//! ```
#[cfg(test)] pub mod error;
mod tests { pub mod kdf;
#[test] pub mod keychain;
fn it_works() {
assert!(true); // Re-export key public types at crate root for ergonomic imports.
} pub use error::{CryptoError, Result};
} pub use kdf::{derive_master_key, generate_salt, DerivedKey, DERIVED_KEY_LEN, SALT_LEN};

View File

@ -0,0 +1,38 @@
# ADR-0002: chronogrid_crypto – Argon2id KDF & OS Keychain Strategy
* **Status**: Accepted
* **Date**: 2026-08-09
* **Deciders**: Michael Kell
## Context & Problem Statement
Task 1.2 requires implementing a `chronogrid_crypto` crate that provides:
1. A master-key derivation function using Argon2id with parameters mandated by `docs/ARCHITECTURE/02-security-and-crypto.md`.
2. Cross-platform OS keychain bindings for secure credential persistence (Windows, Linux). Android is deferred to the Flutter KeyStore bridge.
## Options Considered
1. **Hand-roll Argon2id via raw `blake2` + memory-hard loop** — maximum control but high risk of implementation bugs in a security-critical path.
2. **Use the `argon2` crate (RustCrypto)** — well-audited, widely used, supports Argon2id with configurable parameters out of the box.
3. **Use `ring` or `sodiumoxide`** — neither provides Argon2id natively; would still need the `argon2` crate.
For keychain:
1. **Direct Win32 / libsecret FFI** — full control, but significant per-platform maintenance burden.
2. **`keyring` crate** — abstracts Windows Credential Manager, Linux SecretService, and macOS Keychain behind a single API.
## Decision Outcome
**Chosen Options:**
- `argon2` crate v0.5 (RustCrypto) for KDF — spec-compliant, zero-copy output, and widely audited.
- `keyring` crate v3 for cross-platform OS keychain — minimal code, covers Windows + Linux targets. Android is excluded via `cfg(not(target_os = "android"))` and will be handled by the Flutter KeyStore bridge.
**Key design choices:**
- Argon2id parameters: memory = 65 536 KiB (64 MiB), time_cost = 3, parallelism = 4, salt = 16 bytes, output = 32 bytes.
- `DerivedKey` struct wraps key + salt and implements `Zeroize + Drop` for automatic memory scrubbing.
- `Debug` impl on `DerivedKey` redacts key material to prevent accidental log leaks.
- Raw bytes are Base64-encoded before keychain insertion for UTF-8 credential backend compatibility, using inline encode/decode helpers (no extra dependency).
## Consequences & Trade-offs
* **Positive**: Spec-compliant crypto with secure memory handling; single cross-platform keychain API; 8 unit tests + 1 doc-test provide baseline confidence.
* **Negative**: `keyring` v3 does not support Android — Android keychain must be wired separately via Flutter platform channels (planned for Task 1.5). Inline Base64 is minimal but could be replaced by `base64` crate if other crates need it later.

View File

@ -3,7 +3,7 @@
## Status: Ready for Agent Execution ## Status: Ready for Agent Execution
- [x] **Task 1.1**: Initialize Rust Cargo Workspace in crates/ and configure root Cargo.toml. - [x] **Task 1.1**: Initialize Rust Cargo Workspace in crates/ and configure root Cargo.toml.
- [ ] **Task 1.2**: Implement chronogrid_crypto crate with Argon2id master key derivation and OS keychain bindings. - [x] **Task 1.2**: Implement chronogrid_crypto crate with Argon2id master key derivation and OS keychain bindings.
- [ ] **Task 1.3**: Implement chronogrid_core database initializers using SQLCipher 4.x matching docs/API_CONTRACTS/schema.sql. - [ ] **Task 1.3**: Implement chronogrid_core database initializers using SQLCipher 4.x matching docs/API_CONTRACTS/schema.sql.
- [ ] **Task 1.4**: Integrate Automerge Rust crate into chronogrid_core for task and note CRDT register mutations. - [ ] **Task 1.4**: Integrate Automerge Rust crate into chronogrid_core for task and note CRDT register mutations.
- [ ] **Task 1.5**: Generate lutter_rust_bridge FFI bindings bridging Rust storage methods to Dart. - [ ] **Task 1.5**: Generate lutter_rust_bridge FFI bindings bridging Rust storage methods to Dart.

View File

@ -1 +0,0 @@
{"rustc_fingerprint":7332174474168670565,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\mtkel\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_has_atomic_primitive_alignment=\"128\"\ntarget_has_atomic_primitive_alignment=\"16\"\ntarget_has_atomic_primitive_alignment=\"32\"\ntarget_has_atomic_primitive_alignment=\"64\"\ntarget_has_atomic_primitive_alignment=\"8\"\ntarget_has_atomic_primitive_alignment=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""},"6487759729724009271":{"success":true,"status":"","code":0,"stdout":"rustc 1.97.1 (8bab26f4f 2026-07-14)\nbinary: rustc\ncommit-hash: 8bab26f4f68e0e26f0bb7960be334d5b520ea452\ncommit-date: 2026-07-14\nhost: x86_64-pc-windows-msvc\nrelease: 1.97.1\nLLVM version: 22.1.6\n","stderr":""}},"successes":{}}

View File

@ -1,3 +0,0 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by cargo.
# For information about cache directory tags see https://bford.info/cachedir/

View File

@ -1 +0,0 @@
This file has an mtime of when this was started.

View File

@ -1 +0,0 @@
{"rustc":3720210673988096810,"features":"[]","declared_features":"[]","target":16581365484989365424,"profile":17672942494452627365,"path":16933786149472085738,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\chronogrid_ai-866b27a55c7f8eb2\\dep-lib-chronogrid_ai","checksum":false}}],"rustflags":[],"config":9396254390672932401,"compile_kind":0}

View File

@ -1 +0,0 @@
This file has an mtime of when this was started.

View File

@ -1 +0,0 @@
{"rustc":3720210673988096810,"features":"[]","declared_features":"[]","target":11075585498958225338,"profile":17672942494452627365,"path":14862345418060161617,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\chronogrid_core-4b25f3369283ba7e\\dep-lib-chronogrid_core","checksum":false}}],"rustflags":[],"config":9396254390672932401,"compile_kind":0}

View File

@ -1 +0,0 @@
This file has an mtime of when this was started.

View File

@ -1 +0,0 @@
{"rustc":3720210673988096810,"features":"[]","declared_features":"[]","target":610712738496679682,"profile":17672942494452627365,"path":2818304346370217794,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\chronogrid_crypto-2743a56e71ece37d\\dep-lib-chronogrid_crypto","checksum":false}}],"rustflags":[],"config":9396254390672932401,"compile_kind":0}

View File

@ -1 +0,0 @@
This file has an mtime of when this was started.

View File

@ -1 +0,0 @@
{"rustc":3720210673988096810,"features":"[]","declared_features":"[]","target":8139598702224368756,"profile":17672942494452627365,"path":17680580845150480923,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\chronogrid_net-e3495be9d1f94e7e\\dep-lib-chronogrid_net","checksum":false}}],"rustflags":[],"config":9396254390672932401,"compile_kind":0}

View File

@ -1,5 +0,0 @@
C:\programming\chronogrid\target\debug\deps\chronogrid_ai-866b27a55c7f8eb2.d: crates\chronogrid_ai\src\lib.rs
C:\programming\chronogrid\target\debug\deps\libchronogrid_ai-866b27a55c7f8eb2.rmeta: crates\chronogrid_ai\src\lib.rs
crates\chronogrid_ai\src\lib.rs:

View File

@ -1,5 +0,0 @@
C:\programming\chronogrid\target\debug\deps\chronogrid_core-4b25f3369283ba7e.d: crates\chronogrid_core\src\lib.rs
C:\programming\chronogrid\target\debug\deps\libchronogrid_core-4b25f3369283ba7e.rmeta: crates\chronogrid_core\src\lib.rs
crates\chronogrid_core\src\lib.rs:

View File

@ -1,5 +0,0 @@
C:\programming\chronogrid\target\debug\deps\chronogrid_crypto-2743a56e71ece37d.d: crates\chronogrid_crypto\src\lib.rs
C:\programming\chronogrid\target\debug\deps\libchronogrid_crypto-2743a56e71ece37d.rmeta: crates\chronogrid_crypto\src\lib.rs
crates\chronogrid_crypto\src\lib.rs:

View File

@ -1,5 +0,0 @@
C:\programming\chronogrid\target\debug\deps\chronogrid_net-e3495be9d1f94e7e.d: crates\chronogrid_net\src\lib.rs
C:\programming\chronogrid\target\debug\deps\libchronogrid_net-e3495be9d1f94e7e.rmeta: crates\chronogrid_net\src\lib.rs
crates\chronogrid_net\src\lib.rs: