commit 90317cf999db821a0fb6a719814ed6f68497de43
parent 61baa10c9ad6abb858c0f83f48c525cc967eeec4
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Apr 2026 17:24:09 +0000
protected-store: add authenticated envelope crate
Diffstat:
10 files changed, 495 insertions(+), 0 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -2508,6 +2508,18 @@ dependencies = [
]
[[package]]
+name = "radroots-protected-store"
+version = "0.1.0-alpha.1"
+dependencies = [
+ "chacha20poly1305",
+ "getrandom 0.2.17",
+ "radroots-secret-vault",
+ "serde",
+ "serde_json",
+ "zeroize",
+]
+
+[[package]]
name = "radroots-replica-db"
version = "0.1.0-alpha.1"
dependencies = [
diff --git a/Cargo.toml b/Cargo.toml
@@ -37,6 +37,7 @@ members = [
"crates/replica-db-wasm",
"crates/trade",
"crates/types",
+ "crates/protected-store",
"crates/xtask",
]
resolver = "2"
@@ -86,10 +87,12 @@ radroots-replica-db-wasm = { path = "crates/replica-db-wasm", version = "0.1.0-a
radroots-replica-sync-wasm = { path = "crates/replica-sync-wasm", version = "0.1.0-alpha.1" }
radroots-trade = { path = "crates/trade", version = "0.1.0-alpha.1", default-features = false }
radroots-types = { path = "crates/types", version = "0.1.0-alpha.1", default-features = false }
+radroots-protected-store = { path = "crates/protected-store", version = "0.1.0-alpha.1", default-features = false }
radroots-secret-vault = { path = "crates/secret-vault", version = "0.1.0-alpha.1", default-features = false }
anyhow = { version = "1" }
base64 = { version = "0.22" }
+chacha20poly1305 = { version = "0.10.1", default-features = false, features = ["alloc"] }
cfg-if = { version = "1" }
chrono = { version = "0.4" }
clap = { version = "4" }
diff --git a/contract/coverage/policy.toml b/contract/coverage/policy.toml
@@ -27,6 +27,7 @@ crates = [
"radroots-nostr-signer",
"radroots-nostr-ndb",
"radroots-nostr-runtime",
+ "radroots-protected-store",
"radroots-runtime",
"radroots-secret-vault",
"radroots-simplex-chat-proto",
diff --git a/contract/release/publish-set.toml b/contract/release/publish-set.toml
@@ -7,6 +7,7 @@ crates = [
"radroots-types",
"radroots-events",
"radroots-log",
+ "radroots-protected-store",
"radroots-runtime",
"radroots-secret-vault",
"radroots-simplex-chat-proto",
@@ -44,6 +45,7 @@ crates = [
"radroots-types",
"radroots-log",
"radroots-events",
+ "radroots-protected-store",
"radroots-runtime",
"radroots-secret-vault",
"radroots-simplex-chat-proto",
diff --git a/crates/protected-store/Cargo.toml b/crates/protected-store/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "radroots-protected-store"
+version = "0.1.0-alpha.1"
+edition.workspace = true
+authors = [
+ "Radroots Authors",
+]
+rust-version.workspace = true
+license.workspace = true
+description = "versioned authenticated local protected-store envelopes for radroots runtimes"
+repository.workspace = true
+homepage.workspace = true
+documentation = "https://docs.rs/radroots-protected-store"
+readme = "README.md"
+
+[features]
+default = []
+std = ["radroots-secret-vault/std"]
+
+[dependencies]
+chacha20poly1305 = { workspace = true }
+getrandom = { workspace = true }
+radroots-secret-vault = { workspace = true }
+serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
+zeroize = { workspace = true }
diff --git a/crates/protected-store/README.md b/crates/protected-store/README.md
@@ -0,0 +1,3 @@
+# radroots-protected-store
+
+Versioned authenticated local protected-store envelopes for Rad Roots runtimes.
diff --git a/crates/protected-store/src/error.rs b/crates/protected-store/src/error.rs
@@ -0,0 +1,40 @@
+use core::fmt;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum RadrootsProtectedStoreError {
+ EntropyUnavailable,
+ UnsupportedEnvelopeVersion(u8),
+ InvalidStoreKeyLength(usize),
+ EnvelopeEncodeFailed,
+ EnvelopeDecodeFailed,
+ KeyWrapFailed,
+ KeyUnwrapFailed,
+ EncryptFailed,
+ DecryptFailed,
+}
+
+impl fmt::Display for RadrootsProtectedStoreError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::EntropyUnavailable => f.write_str("protected-store entropy is unavailable"),
+ Self::UnsupportedEnvelopeVersion(version) => {
+ write!(
+ f,
+ "protected-store envelope version {version} is unsupported"
+ )
+ }
+ Self::InvalidStoreKeyLength(length) => {
+ write!(f, "protected-store key must be 32 bytes, got {length}")
+ }
+ Self::EnvelopeEncodeFailed => f.write_str("protected-store envelope encoding failed"),
+ Self::EnvelopeDecodeFailed => f.write_str("protected-store envelope decoding failed"),
+ Self::KeyWrapFailed => f.write_str("protected-store key wrapping failed"),
+ Self::KeyUnwrapFailed => f.write_str("protected-store key unwrapping failed"),
+ Self::EncryptFailed => f.write_str("protected-store encryption failed"),
+ Self::DecryptFailed => f.write_str("protected-store decryption failed"),
+ }
+ }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for RadrootsProtectedStoreError {}
diff --git a/crates/protected-store/src/lib.rs b/crates/protected-store/src/lib.rs
@@ -0,0 +1,395 @@
+#![forbid(unsafe_code)]
+#![no_std]
+
+extern crate alloc;
+#[cfg(any(feature = "std", test))]
+extern crate std;
+
+pub mod error;
+
+use alloc::string::String;
+use alloc::vec::Vec;
+use chacha20poly1305::aead::{Aead, KeyInit, Payload};
+use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
+use error::RadrootsProtectedStoreError;
+use getrandom::getrandom;
+use radroots_secret_vault::RadrootsSecretKeyWrapping;
+use serde::{Deserialize, Serialize};
+use zeroize::Zeroize;
+
+pub const RADROOTS_PROTECTED_STORE_ENVELOPE_VERSION: u8 = 1;
+pub const RADROOTS_PROTECTED_STORE_KEY_LENGTH: usize = 32;
+pub const RADROOTS_PROTECTED_STORE_NONCE_LENGTH: usize = 24;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum RadrootsProtectedStoreCipher {
+ XChaCha20Poly1305,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum RadrootsProtectedStoreKeySource {
+ SecretVaultWrapped,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct RadrootsProtectedStoreHeader {
+ pub version: u8,
+ pub cipher: RadrootsProtectedStoreCipher,
+ pub key_source: RadrootsProtectedStoreKeySource,
+ pub key_slot: String,
+ pub nonce: [u8; RADROOTS_PROTECTED_STORE_NONCE_LENGTH],
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct RadrootsProtectedStoreEnvelope {
+ pub header: RadrootsProtectedStoreHeader,
+ pub wrapped_key: Vec<u8>,
+ pub ciphertext: Vec<u8>,
+}
+
+#[derive(Debug, Serialize)]
+struct RadrootsProtectedStoreAad<'a> {
+ version: u8,
+ cipher: RadrootsProtectedStoreCipher,
+ key_source: RadrootsProtectedStoreKeySource,
+ key_slot: &'a str,
+ nonce: &'a [u8; RADROOTS_PROTECTED_STORE_NONCE_LENGTH],
+ wrapped_key: &'a [u8],
+}
+
+impl RadrootsProtectedStoreEnvelope {
+ pub fn seal_with_wrapped_key<V>(
+ vault: &V,
+ key_slot: &str,
+ plaintext: &[u8],
+ ) -> Result<Self, RadrootsProtectedStoreError>
+ where
+ V: RadrootsSecretKeyWrapping,
+ {
+ let mut store_key = [0_u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH];
+ let mut nonce = [0_u8; RADROOTS_PROTECTED_STORE_NONCE_LENGTH];
+ getrandom(&mut store_key).map_err(|_| RadrootsProtectedStoreError::EntropyUnavailable)?;
+ getrandom(&mut nonce).map_err(|_| RadrootsProtectedStoreError::EntropyUnavailable)?;
+ let result =
+ Self::seal_with_wrapped_key_and_material(vault, key_slot, plaintext, store_key, nonce);
+ store_key.zeroize();
+ result
+ }
+
+ pub fn seal_with_wrapped_key_and_material<V>(
+ vault: &V,
+ key_slot: &str,
+ plaintext: &[u8],
+ mut store_key: [u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH],
+ nonce: [u8; RADROOTS_PROTECTED_STORE_NONCE_LENGTH],
+ ) -> Result<Self, RadrootsProtectedStoreError>
+ where
+ V: RadrootsSecretKeyWrapping,
+ {
+ let wrapped_key = vault
+ .wrap_data_key(key_slot, &store_key)
+ .map_err(|_| RadrootsProtectedStoreError::KeyWrapFailed)?;
+
+ let header = RadrootsProtectedStoreHeader {
+ version: RADROOTS_PROTECTED_STORE_ENVELOPE_VERSION,
+ cipher: RadrootsProtectedStoreCipher::XChaCha20Poly1305,
+ key_source: RadrootsProtectedStoreKeySource::SecretVaultWrapped,
+ key_slot: String::from(key_slot),
+ nonce,
+ };
+
+ let aad = envelope_aad(&header, &wrapped_key)?;
+ let cipher = XChaCha20Poly1305::new(Key::from_slice(&store_key));
+ let ciphertext = cipher
+ .encrypt(
+ XNonce::from_slice(&header.nonce),
+ Payload {
+ msg: plaintext,
+ aad: &aad,
+ },
+ )
+ .map_err(|_| RadrootsProtectedStoreError::EncryptFailed)?;
+ store_key.zeroize();
+
+ Ok(Self {
+ header,
+ wrapped_key,
+ ciphertext,
+ })
+ }
+
+ pub fn open_with_wrapped_key<V>(
+ &self,
+ vault: &V,
+ ) -> Result<Vec<u8>, RadrootsProtectedStoreError>
+ where
+ V: RadrootsSecretKeyWrapping,
+ {
+ self.validate_header()?;
+ let mut store_key = vault
+ .unwrap_data_key(&self.header.key_slot, &self.wrapped_key)
+ .map_err(|_| RadrootsProtectedStoreError::KeyUnwrapFailed)?;
+
+ if store_key.len() != RADROOTS_PROTECTED_STORE_KEY_LENGTH {
+ let length = store_key.len();
+ store_key.zeroize();
+ return Err(RadrootsProtectedStoreError::InvalidStoreKeyLength(length));
+ }
+
+ let mut store_key_bytes = [0_u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH];
+ store_key_bytes.copy_from_slice(&store_key);
+ store_key.zeroize();
+
+ let aad = envelope_aad(&self.header, &self.wrapped_key)?;
+ let cipher = XChaCha20Poly1305::new(Key::from_slice(&store_key_bytes));
+ let decrypted = cipher
+ .decrypt(
+ XNonce::from_slice(&self.header.nonce),
+ Payload {
+ msg: &self.ciphertext,
+ aad: &aad,
+ },
+ )
+ .map_err(|_| RadrootsProtectedStoreError::DecryptFailed)?;
+ store_key_bytes.zeroize();
+ Ok(decrypted)
+ }
+
+ pub fn encode_json(&self) -> Result<Vec<u8>, RadrootsProtectedStoreError> {
+ serde_json::to_vec(self).map_err(|_| RadrootsProtectedStoreError::EnvelopeEncodeFailed)
+ }
+
+ pub fn decode_json(json: &[u8]) -> Result<Self, RadrootsProtectedStoreError> {
+ let envelope: Self = serde_json::from_slice(json)
+ .map_err(|_| RadrootsProtectedStoreError::EnvelopeDecodeFailed)?;
+ envelope.validate_header()?;
+ Ok(envelope)
+ }
+
+ fn validate_header(&self) -> Result<(), RadrootsProtectedStoreError> {
+ if self.header.version != RADROOTS_PROTECTED_STORE_ENVELOPE_VERSION {
+ return Err(RadrootsProtectedStoreError::UnsupportedEnvelopeVersion(
+ self.header.version,
+ ));
+ }
+
+ Ok(())
+ }
+}
+
+fn envelope_aad(
+ header: &RadrootsProtectedStoreHeader,
+ wrapped_key: &[u8],
+) -> Result<Vec<u8>, RadrootsProtectedStoreError> {
+ serde_json::to_vec(&RadrootsProtectedStoreAad {
+ version: header.version,
+ cipher: header.cipher,
+ key_source: header.key_source,
+ key_slot: &header.key_slot,
+ nonce: &header.nonce,
+ wrapped_key,
+ })
+ .map_err(|_| RadrootsProtectedStoreError::EnvelopeEncodeFailed)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use alloc::string::String;
+ use alloc::vec;
+ use core::cell::{Cell, RefCell};
+
+ struct FakeVault {
+ wrap_calls: Cell<usize>,
+ unwrap_calls: Cell<usize>,
+ fail_wrap: bool,
+ fail_unwrap: bool,
+ last_slot: RefCell<Option<String>>,
+ }
+
+ impl FakeVault {
+ fn new() -> Self {
+ Self {
+ wrap_calls: Cell::new(0),
+ unwrap_calls: Cell::new(0),
+ fail_wrap: false,
+ fail_unwrap: false,
+ last_slot: RefCell::new(None),
+ }
+ }
+
+ fn with_wrap_failure() -> Self {
+ Self {
+ fail_wrap: true,
+ ..Self::new()
+ }
+ }
+
+ fn with_unwrap_failure() -> Self {
+ Self {
+ fail_unwrap: true,
+ ..Self::new()
+ }
+ }
+ }
+
+ impl RadrootsSecretKeyWrapping for FakeVault {
+ type Error = ();
+
+ fn wrap_data_key(
+ &self,
+ key_slot: &str,
+ plaintext_key: &[u8],
+ ) -> Result<Vec<u8>, Self::Error> {
+ if self.fail_wrap {
+ return Err(());
+ }
+ self.wrap_calls.set(self.wrap_calls.get() + 1);
+ self.last_slot.replace(Some(String::from(key_slot)));
+ let mut wrapped = key_slot.as_bytes().to_vec();
+ wrapped.push(0);
+ wrapped.extend(plaintext_key.iter().map(|byte| byte ^ 0x5a));
+ Ok(wrapped)
+ }
+
+ fn unwrap_data_key(
+ &self,
+ key_slot: &str,
+ wrapped_key: &[u8],
+ ) -> Result<Vec<u8>, Self::Error> {
+ if self.fail_unwrap {
+ return Err(());
+ }
+ self.unwrap_calls.set(self.unwrap_calls.get() + 1);
+ self.last_slot.replace(Some(String::from(key_slot)));
+
+ let separator = wrapped_key.iter().position(|byte| *byte == 0).ok_or(())?;
+ if &wrapped_key[..separator] != key_slot.as_bytes() {
+ return Err(());
+ }
+
+ Ok(wrapped_key[separator + 1..]
+ .iter()
+ .map(|byte| byte ^ 0x5a)
+ .collect())
+ }
+ }
+
+ #[test]
+ fn wrapped_key_roundtrip_uses_secret_vault_and_stable_envelope() {
+ let vault = FakeVault::new();
+ let envelope = RadrootsProtectedStoreEnvelope::seal_with_wrapped_key_and_material(
+ &vault,
+ "drafts/default",
+ b"secret draft body",
+ [7_u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH],
+ [9_u8; RADROOTS_PROTECTED_STORE_NONCE_LENGTH],
+ )
+ .expect("seal succeeds");
+
+ assert_eq!(vault.wrap_calls.get(), 1);
+ assert_eq!(
+ envelope.header.version,
+ RADROOTS_PROTECTED_STORE_ENVELOPE_VERSION
+ );
+ assert_eq!(
+ envelope.header.cipher,
+ RadrootsProtectedStoreCipher::XChaCha20Poly1305
+ );
+ assert_eq!(
+ envelope.header.key_source,
+ RadrootsProtectedStoreKeySource::SecretVaultWrapped
+ );
+ assert_eq!(envelope.header.key_slot, "drafts/default");
+
+ let encoded = envelope.encode_json().expect("encode succeeds");
+ let decoded =
+ RadrootsProtectedStoreEnvelope::decode_json(&encoded).expect("decode succeeds");
+ let plaintext = decoded
+ .open_with_wrapped_key(&vault)
+ .expect("open succeeds");
+
+ assert_eq!(vault.unwrap_calls.get(), 1);
+ assert_eq!(plaintext, b"secret draft body");
+ }
+
+ #[test]
+ fn tampered_wrapped_key_fails_authentication() {
+ let vault = FakeVault::new();
+ let mut envelope = RadrootsProtectedStoreEnvelope::seal_with_wrapped_key_and_material(
+ &vault,
+ "drafts/default",
+ b"secret draft body",
+ [3_u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH],
+ [4_u8; RADROOTS_PROTECTED_STORE_NONCE_LENGTH],
+ )
+ .expect("seal succeeds");
+
+ let last = envelope.wrapped_key.len() - 1;
+ envelope.wrapped_key[last] ^= 0x01;
+
+ let err = envelope
+ .open_with_wrapped_key(&vault)
+ .expect_err("tampered wrapped key must fail");
+ assert_eq!(err, RadrootsProtectedStoreError::DecryptFailed);
+ }
+
+ #[test]
+ fn unsupported_version_is_rejected() {
+ let envelope = RadrootsProtectedStoreEnvelope {
+ header: RadrootsProtectedStoreHeader {
+ version: 2,
+ cipher: RadrootsProtectedStoreCipher::XChaCha20Poly1305,
+ key_source: RadrootsProtectedStoreKeySource::SecretVaultWrapped,
+ key_slot: String::from("drafts/default"),
+ nonce: [0_u8; RADROOTS_PROTECTED_STORE_NONCE_LENGTH],
+ },
+ wrapped_key: vec![1, 2, 3],
+ ciphertext: vec![4, 5, 6],
+ };
+
+ let encoded = envelope.encode_json().expect("encode succeeds");
+ let err = RadrootsProtectedStoreEnvelope::decode_json(&encoded)
+ .expect_err("unsupported version must fail");
+ assert_eq!(
+ err,
+ RadrootsProtectedStoreError::UnsupportedEnvelopeVersion(2)
+ );
+ }
+
+ #[test]
+ fn wrap_failures_are_delegated_to_secret_vault() {
+ let vault = FakeVault::with_wrap_failure();
+ let err = RadrootsProtectedStoreEnvelope::seal_with_wrapped_key_and_material(
+ &vault,
+ "drafts/default",
+ b"secret draft body",
+ [7_u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH],
+ [9_u8; RADROOTS_PROTECTED_STORE_NONCE_LENGTH],
+ )
+ .expect_err("wrap failure must surface");
+
+ assert_eq!(err, RadrootsProtectedStoreError::KeyWrapFailed);
+ }
+
+ #[test]
+ fn unwrap_failures_are_delegated_to_secret_vault() {
+ let seal_vault = FakeVault::new();
+ let open_vault = FakeVault::with_unwrap_failure();
+ let envelope = RadrootsProtectedStoreEnvelope::seal_with_wrapped_key_and_material(
+ &seal_vault,
+ "drafts/default",
+ b"secret draft body",
+ [7_u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH],
+ [9_u8; RADROOTS_PROTECTED_STORE_NONCE_LENGTH],
+ )
+ .expect("seal succeeds");
+
+ let err = envelope
+ .open_with_wrapped_key(&open_vault)
+ .expect_err("unwrap failure must surface");
+ assert_eq!(err, RadrootsProtectedStoreError::KeyUnwrapFailed);
+ }
+}
diff --git a/crates/secret-vault/src/lib.rs b/crates/secret-vault/src/lib.rs
@@ -1,6 +1,7 @@
#![forbid(unsafe_code)]
#![no_std]
+extern crate alloc;
#[cfg(any(feature = "std", test))]
extern crate std;
@@ -8,6 +9,7 @@ pub mod backend;
pub mod error;
pub mod policy;
pub mod selection;
+pub mod wrap;
pub mod prelude {
pub use crate::backend::{RadrootsSecretBackend, RadrootsSecretBackendKind};
@@ -20,6 +22,7 @@ pub mod prelude {
RadrootsResolvedSecretBackend, RadrootsSecretBackendAvailability,
RadrootsSecretBackendSelection,
};
+ pub use crate::wrap::RadrootsSecretKeyWrapping;
}
pub use backend::{RadrootsSecretBackend, RadrootsSecretBackendKind};
@@ -32,3 +35,4 @@ pub use selection::{
RadrootsResolvedSecretBackend, RadrootsSecretBackendAvailability,
RadrootsSecretBackendSelection,
};
+pub use wrap::RadrootsSecretKeyWrapping;
diff --git a/crates/secret-vault/src/wrap.rs b/crates/secret-vault/src/wrap.rs
@@ -0,0 +1,9 @@
+use alloc::vec::Vec;
+
+pub trait RadrootsSecretKeyWrapping {
+ type Error;
+
+ fn wrap_data_key(&self, key_slot: &str, plaintext_key: &[u8]) -> Result<Vec<u8>, Self::Error>;
+
+ fn unwrap_data_key(&self, key_slot: &str, wrapped_key: &[u8]) -> Result<Vec<u8>, Self::Error>;
+}