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:
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,