app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit f2a923a757420875d6cefe5e62433686238b9e4b
parent 3c47d34904c41d896c40623f44a3e7ad64e19474
Author: triesap <triesap@radroots.dev>
Date:   Mon, 19 Jan 2026 01:17:45 +0000

app-core: add backup codec helpers

- add base64 helpers and backup codec exports
- implement wasm bundle encode/decode via AES-GCM
- add serde models for backup bundle and crypto registry
- add base64/serde/wasm deps for backup serialization

Diffstat:
MCargo.lock | 1+
MCargo.toml | 1+
Mcrates/core/Cargo.toml | 1+
Acrates/core/src/backup/codec.rs | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/core/src/backup/mod.rs | 7+++++++
Mcrates/core/src/backup/types.rs | 25++++++++++++++-----------
Mcrates/core/src/crypto/types.rs | 17++++++++++-------
7 files changed, 235 insertions(+), 18 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1161,6 +1161,7 @@ name = "radroots-app-core" version = "0.1.0" dependencies = [ "async-trait", + "base64", "getrandom 0.2.17", "js-sys", "serde", diff --git a/Cargo.toml b/Cargo.toml @@ -21,6 +21,7 @@ getrandom = "0.2" js-sys = "0.3.77" web-sys = { version = "0.3.77", features = ["Crypto", "CryptoKey", "SubtleCrypto", "Window"] } wasm-bindgen-futures = "0.4" +base64 = "0.22" [profile.release] codegen-units = 1 diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml @@ -14,6 +14,7 @@ async-trait = "0.1.83" serde = { workspace = true } serde_json = { workspace = true } getrandom = { workspace = true } +base64 = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = { workspace = true } diff --git a/crates/core/src/backup/codec.rs b/crates/core/src/backup/codec.rs @@ -0,0 +1,201 @@ +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; + +#[cfg(target_arch = "wasm32")] +use crate::crypto::RadrootsClientCryptoError; +#[cfg(target_arch = "wasm32")] +use crate::crypto::{ + crypto_kdf_iterations_default, + crypto_kdf_salt_create, +}; +use crate::crypto::RadrootsClientKeyMaterialProvider; + +use super::{ + RadrootsClientBackupBundle, + RadrootsClientBackupError, +}; +#[cfg(target_arch = "wasm32")] +use super::RadrootsClientBackupBundleEnvelope; + +pub fn backup_bytes_to_b64(bytes: &[u8]) -> Result<String, RadrootsClientBackupError> { + Ok(STANDARD.encode(bytes)) +} + +pub fn backup_b64_to_bytes(value: &str) -> Result<Vec<u8>, RadrootsClientBackupError> { + STANDARD + .decode(value) + .map_err(|_| RadrootsClientBackupError::DecodeFailure) +} + +#[cfg(target_arch = "wasm32")] +fn map_crypto_error( + err: RadrootsClientCryptoError, + fallback: RadrootsClientBackupError, +) -> RadrootsClientBackupError { + match err { + RadrootsClientCryptoError::CryptoUndefined => { + RadrootsClientBackupError::CryptoUndefined + } + _ => fallback, + } +} + +#[cfg(target_arch = "wasm32")] +async fn encrypt_bytes( + key: &web_sys::CryptoKey, + iv: &[u8], + plaintext: &[u8], +) -> Result<Vec<u8>, RadrootsClientBackupError> { + let window = web_sys::window().ok_or(RadrootsClientBackupError::CryptoUndefined)?; + let crypto = window + .crypto() + .map_err(|_| RadrootsClientBackupError::CryptoUndefined)?; + let subtle = crypto.subtle(); + let algo = js_sys::Object::new(); + js_sys::Reflect::set(&algo, &"name".into(), &"AES-GCM".into()) + .map_err(|_| RadrootsClientBackupError::EncodeFailure)?; + let iv_array = js_sys::Uint8Array::from(iv); + js_sys::Reflect::set(&algo, &"iv".into(), &iv_array.into()) + .map_err(|_| RadrootsClientBackupError::EncodeFailure)?; + let promise = subtle + .encrypt_with_object_and_u8_array(&algo, key, plaintext) + .map_err(|_| RadrootsClientBackupError::EncodeFailure)?; + let value = wasm_bindgen_futures::JsFuture::from(promise) + .await + .map_err(|_| RadrootsClientBackupError::EncodeFailure)?; + let array = js_sys::Uint8Array::new(&value); + let mut out = vec![0u8; array.length() as usize]; + array.copy_to(&mut out); + Ok(out) +} + +#[cfg(target_arch = "wasm32")] +async fn decrypt_bytes( + key: &web_sys::CryptoKey, + iv: &[u8], + ciphertext: &[u8], +) -> Result<Vec<u8>, RadrootsClientBackupError> { + let window = web_sys::window().ok_or(RadrootsClientBackupError::CryptoUndefined)?; + let crypto = window + .crypto() + .map_err(|_| RadrootsClientBackupError::CryptoUndefined)?; + let subtle = crypto.subtle(); + let algo = js_sys::Object::new(); + js_sys::Reflect::set(&algo, &"name".into(), &"AES-GCM".into()) + .map_err(|_| RadrootsClientBackupError::DecodeFailure)?; + let iv_array = js_sys::Uint8Array::from(iv); + js_sys::Reflect::set(&algo, &"iv".into(), &iv_array.into()) + .map_err(|_| RadrootsClientBackupError::DecodeFailure)?; + let promise = subtle + .decrypt_with_object_and_u8_array(&algo, key, ciphertext) + .map_err(|_| RadrootsClientBackupError::DecodeFailure)?; + let value = wasm_bindgen_futures::JsFuture::from(promise) + .await + .map_err(|_| RadrootsClientBackupError::DecodeFailure)?; + let array = js_sys::Uint8Array::new(&value); + let mut out = vec![0u8; array.length() as usize]; + array.copy_to(&mut out); + Ok(out) +} + +#[cfg(target_arch = "wasm32")] +pub async fn backup_bundle_encode( + bundle: &RadrootsClientBackupBundle, + provider: &dyn RadrootsClientKeyMaterialProvider, +) -> Result<Vec<u8>, RadrootsClientBackupError> { + let json = serde_json::to_string(bundle) + .map_err(|_| RadrootsClientBackupError::EncodeFailure)?; + let plaintext = json.into_bytes(); + let salt = crypto_kdf_salt_create(16) + .map_err(|e| map_crypto_error(e, RadrootsClientBackupError::EncodeFailure))?; + let iterations = crypto_kdf_iterations_default(); + let mut material = provider + .get_key_material() + .await + .map_err(|e| map_crypto_error(e, RadrootsClientBackupError::EncodeFailure))?; + let kek = crate::crypto::crypto_kdf_derive_kek(&material, &salt, iterations) + .await + .map_err(|e| map_crypto_error(e, RadrootsClientBackupError::EncodeFailure))?; + material.fill(0); + let mut iv = vec![0u8; 12]; + crate::crypto::random::fill_random(&mut iv) + .map_err(|e| map_crypto_error(e, RadrootsClientBackupError::EncodeFailure))?; + let ciphertext = encrypt_bytes(&kek, &iv, &plaintext).await?; + let envelope = RadrootsClientBackupBundleEnvelope { + version: 1, + created_at: js_sys::Date::now() as u64, + kdf_salt_b64: backup_bytes_to_b64(&salt)?, + kdf_iterations: iterations, + iv_b64: backup_bytes_to_b64(&iv)?, + ciphertext_b64: backup_bytes_to_b64(&ciphertext)?, + }; + let encoded = serde_json::to_string(&envelope) + .map_err(|_| RadrootsClientBackupError::EncodeFailure)?; + Ok(encoded.into_bytes()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn backup_bundle_encode( + _bundle: &RadrootsClientBackupBundle, + _provider: &dyn RadrootsClientKeyMaterialProvider, +) -> Result<Vec<u8>, RadrootsClientBackupError> { + Err(RadrootsClientBackupError::CryptoUndefined) +} + +#[cfg(target_arch = "wasm32")] +pub async fn backup_bundle_decode( + blob: &[u8], + provider: &dyn RadrootsClientKeyMaterialProvider, +) -> Result<RadrootsClientBackupBundle, RadrootsClientBackupError> { + let json = std::str::from_utf8(blob) + .map_err(|_| RadrootsClientBackupError::DecodeFailure)?; + let envelope: RadrootsClientBackupBundleEnvelope = serde_json::from_str(json) + .map_err(|_| RadrootsClientBackupError::InvalidBundle)?; + let salt = backup_b64_to_bytes(&envelope.kdf_salt_b64)?; + let iv = backup_b64_to_bytes(&envelope.iv_b64)?; + let ciphertext = backup_b64_to_bytes(&envelope.ciphertext_b64)?; + let mut material = provider + .get_key_material() + .await + .map_err(|e| map_crypto_error(e, RadrootsClientBackupError::DecodeFailure))?; + let kek = crate::crypto::crypto_kdf_derive_kek( + &material, + &salt, + envelope.kdf_iterations, + ) + .await + .map_err(|e| map_crypto_error(e, RadrootsClientBackupError::DecodeFailure))?; + material.fill(0); + let plaintext = decrypt_bytes(&kek, &iv, &ciphertext).await?; + let payload = std::str::from_utf8(&plaintext) + .map_err(|_| RadrootsClientBackupError::DecodeFailure)?; + serde_json::from_str(payload) + .map_err(|_| RadrootsClientBackupError::InvalidBundle) +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn backup_bundle_decode( + _blob: &[u8], + _provider: &dyn RadrootsClientKeyMaterialProvider, +) -> Result<RadrootsClientBackupBundle, RadrootsClientBackupError> { + Err(RadrootsClientBackupError::CryptoUndefined) +} + +#[cfg(test)] +mod tests { + use super::{backup_b64_to_bytes, backup_bytes_to_b64}; + + #[test] + fn base64_roundtrip() { + let data = b"radroots"; + let encoded = backup_bytes_to_b64(data).expect("encode"); + let decoded = backup_b64_to_bytes(&encoded).expect("decode"); + assert_eq!(decoded, data); + } + + #[test] + fn base64_decode_rejects_invalid() { + let err = backup_b64_to_bytes("not-base64").expect_err("invalid"); + assert_eq!(err, super::RadrootsClientBackupError::DecodeFailure); + } +} diff --git a/crates/core/src/backup/mod.rs b/crates/core/src/backup/mod.rs @@ -1,5 +1,6 @@ pub mod error; pub mod types; +pub mod codec; pub use error::{RadrootsClientBackupError, RadrootsClientBackupErrorMessage}; pub use types::{ @@ -20,3 +21,9 @@ pub use types::{ RadrootsClientBackupStoreRef, RADROOTS_CLIENT_BACKUP_BUNDLE_VERSION, }; +pub use codec::{ + backup_b64_to_bytes, + backup_bytes_to_b64, + backup_bundle_decode, + backup_bundle_encode, +}; diff --git a/crates/core/src/backup/types.rs b/crates/core/src/backup/types.rs @@ -1,4 +1,5 @@ use async_trait::async_trait; +use serde::{Deserialize, Serialize}; use crate::crypto::RadrootsClientCryptoRegistryExport; @@ -6,7 +7,8 @@ pub type RadrootsClientBackupBundleVersion = u8; pub const RADROOTS_CLIENT_BACKUP_BUNDLE_VERSION: RadrootsClientBackupBundleVersion = 1; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum RadrootsClientBackupBundleStoreType { Sql, Keystore, @@ -32,34 +34,35 @@ impl RadrootsClientBackupBundleStoreType { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RadrootsClientBackupSqlPayload { pub bytes_b64: String, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RadrootsClientBackupKeystoreEntry { pub key: String, pub value: String, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RadrootsClientBackupKeystorePayload { pub entries: Vec<RadrootsClientBackupKeystoreEntry>, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RadrootsClientBackupDatastoreEntry { pub key: String, pub value: String, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RadrootsClientBackupDatastorePayload { pub entries: Vec<RadrootsClientBackupDatastoreEntry>, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "store_type", rename_all = "lowercase")] pub enum RadrootsClientBackupBundlePayload { Sql { store_id: String, @@ -99,13 +102,13 @@ impl RadrootsClientBackupBundlePayload { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RadrootsClientBackupStoreRef { pub store_id: String, pub store_type: RadrootsClientBackupBundleStoreType, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RadrootsClientBackupBundleManifest { pub version: RadrootsClientBackupBundleVersion, pub created_at: u64, @@ -114,13 +117,13 @@ pub struct RadrootsClientBackupBundleManifest { pub crypto_registry: RadrootsClientCryptoRegistryExport, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RadrootsClientBackupBundle { pub manifest: RadrootsClientBackupBundleManifest, pub payloads: Vec<RadrootsClientBackupBundlePayload>, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RadrootsClientBackupBundleEnvelope { pub version: u8, pub created_at: u64, diff --git a/crates/core/src/crypto/types.rs b/crates/core/src/crypto/types.rs @@ -1,10 +1,12 @@ use async_trait::async_trait; +use serde::{Deserialize, Serialize}; use crate::idb::RadrootsClientIdbConfig; use super::RadrootsClientCryptoError; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum RadrootsClientCryptoKeyStatus { Active, Rotated, @@ -27,8 +29,9 @@ impl RadrootsClientCryptoKeyStatus { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum RadrootsClientCryptoAlgorithm { + #[serde(rename = "AES-GCM")] AesGcm, } @@ -47,7 +50,7 @@ impl RadrootsClientCryptoAlgorithm { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RadrootsClientCryptoEnvelope { pub version: u8, pub key_id: String, @@ -56,7 +59,7 @@ pub struct RadrootsClientCryptoEnvelope { pub ciphertext: Vec<u8>, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RadrootsClientCryptoKeyEntry { pub key_id: String, pub store_id: String, @@ -71,7 +74,7 @@ pub struct RadrootsClientCryptoKeyEntry { pub provider_id: String, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RadrootsClientCryptoStoreIndex { pub store_id: String, pub active_key_id: String, @@ -79,13 +82,13 @@ pub struct RadrootsClientCryptoStoreIndex { pub created_at: u64, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RadrootsClientCryptoRegistryExport { pub stores: Vec<RadrootsClientCryptoStoreIndex>, pub keys: Vec<RadrootsClientCryptoKeyEntry>, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RadrootsClientCryptoDecryptOutcome { pub plaintext: Vec<u8>, pub needs_reencrypt: bool,