use std::io::Write as _;
use argon2::Argon2;
use chacha20poly1305::{
AeadCore, XChaCha20Poly1305, XNonce,
aead::{Aead, OsRng, Payload, rand_core::RngCore},
};
use freenet_stdlib::prelude::{CodeHash, DelegateKey};
use hkdf::Hkdf;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use zeroize::{Zeroize, Zeroizing};
use super::secrets_store::{ExportSecretEntry, SecretScope, SecretsStore, UserSecretContext};
const MAGIC: &[u8; 4] = b"FNSX";
const BUNDLE_FORMAT_V1: u8 = 1;
const SALT_LEN: usize = 16;
const HEADER_LEN: usize = 4 + 1 + 1 + SALT_LEN + 24;
const TOKEN_BUNDLE_HKDF_INFO: &[u8] = b"freenet-secret-bundle-token-v1";
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum KdfId {
Argon2idPassphrase = 1,
TokenHkdf = 2,
}
impl KdfId {
fn from_byte(b: u8) -> Result<Self, ExportError> {
match b {
1 => Ok(KdfId::Argon2idPassphrase),
2 => Ok(KdfId::TokenHkdf),
other => Err(ExportError::UnknownKdf(other)),
}
}
}
pub enum BundleKeyMaterial<'a> {
Passphrase(&'a [u8]),
Token(&'a [u8]),
}
impl BundleKeyMaterial<'_> {
fn kdf_id(&self) -> KdfId {
match self {
BundleKeyMaterial::Passphrase(_) => KdfId::Argon2idPassphrase,
BundleKeyMaterial::Token(_) => KdfId::TokenHkdf,
}
}
}
#[derive(Serialize, Deserialize)]
struct BundleEntry {
delegate_key: Vec<u8>,
code_hash: Vec<u8>,
secret_hash: Vec<u8>,
plaintext: Vec<u8>,
}
impl Drop for BundleEntry {
fn drop(&mut self) {
self.plaintext.zeroize();
}
}
#[derive(Serialize, Deserialize)]
struct BundlePayload {
schema_version: u32,
source_scope: String,
created_unix_secs: u64,
entries: Vec<BundleEntry>,
}
const PAYLOAD_SCHEMA_V1: u32 = 1;
#[derive(Debug, thiserror::Error)]
pub enum ExportError {
#[error("secrets store error: {0}")]
Store(#[from] super::secrets_store::SecretStoreError),
#[error("runtime error: {0}")]
Runtime(String),
#[error("CBOR serialization error: {0}")]
CborSer(String),
#[error("CBOR deserialization error: {0}")]
CborDe(String),
#[error("argon2 key derivation failed: {0}")]
Argon2(String),
#[error("bundle encryption failed")]
EncryptFailed,
#[error("bundle authentication failed: wrong passphrase/token or corrupt bundle")]
AuthFailed,
#[error("not a freenet secrets bundle (bad magic)")]
BadMagic,
#[error("unsupported bundle format version {0}")]
UnsupportedVersion(u8),
#[error("unknown KDF id {0} in bundle header")]
UnknownKdf(u8),
#[error("bundle truncated: {0}")]
Truncated(&'static str),
#[error("bundle entry has malformed {field} length {len} (expected {expected})")]
BadEntryField {
field: &'static str,
len: usize,
expected: usize,
},
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct ImportReport {
pub imported: usize,
pub skipped: Vec<(String, String)>,
}
fn derive_bundle_key(
material: &BundleKeyMaterial<'_>,
salt: &[u8; SALT_LEN],
) -> Result<Zeroizing<[u8; 32]>, ExportError> {
let mut key = Zeroizing::new([0u8; 32]);
match material {
BundleKeyMaterial::Passphrase(pass) => {
Argon2::default()
.hash_password_into(pass, salt, key.as_mut_slice())
.map_err(|e| ExportError::Argon2(e.to_string()))?;
}
BundleKeyMaterial::Token(token) => {
let hk = Hkdf::<Sha256>::new(Some(salt.as_slice()), token);
hk.expand(TOKEN_BUNDLE_HKDF_INFO, key.as_mut_slice())
.expect("HKDF expand with 32-byte OKM never fails for SHA-256");
}
}
Ok(key)
}
fn seal_bundle(
entries: &[ExportSecretEntry],
source_scope: &str,
material: &BundleKeyMaterial<'_>,
) -> Result<Vec<u8>, ExportError> {
let payload = BundlePayload {
schema_version: PAYLOAD_SCHEMA_V1,
source_scope: source_scope.to_string(),
created_unix_secs: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
entries: entries
.iter()
.map(|e| BundleEntry {
delegate_key: e.delegate_key.bytes().to_vec(),
code_hash: e.delegate_key.code_hash().as_ref().to_vec(),
secret_hash: e.secret_hash.to_vec(),
plaintext: e.plaintext.to_vec(),
})
.collect(),
};
let mut plaintext = Zeroizing::new(Vec::new());
ciborium::ser::into_writer(&payload, &mut *plaintext)
.map_err(|e| ExportError::CborSer(e.to_string()))?;
let mut salt = [0u8; SALT_LEN];
OsRng.fill_bytes(&mut salt);
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let kdf_id = material.kdf_id();
let key = derive_bundle_key(material, &salt)?;
let cipher = <XChaCha20Poly1305 as chacha20poly1305::KeyInit>::new(key.as_slice().into());
let mut out = Vec::with_capacity(HEADER_LEN + plaintext.len() + 16);
out.extend_from_slice(MAGIC);
out.push(BUNDLE_FORMAT_V1);
out.push(kdf_id as u8);
out.extend_from_slice(&salt);
out.extend_from_slice(nonce.as_slice());
debug_assert_eq!(out.len(), HEADER_LEN);
let aad = out.clone(); let ciphertext = cipher
.encrypt(
&nonce,
Payload {
msg: plaintext.as_slice(),
aad: &aad,
},
)
.map_err(|_| {
ExportError::EncryptFailed
})?;
out.extend_from_slice(&ciphertext);
Ok(out)
}
fn open_bundle(
bundle: &[u8],
material: &BundleKeyMaterial<'_>,
) -> Result<BundlePayload, ExportError> {
if bundle.len() < HEADER_LEN {
return Err(ExportError::Truncated("shorter than header"));
}
if &bundle[0..4] != MAGIC {
return Err(ExportError::BadMagic);
}
let version = bundle[4];
if version != BUNDLE_FORMAT_V1 {
return Err(ExportError::UnsupportedVersion(version));
}
let kdf_id = KdfId::from_byte(bundle[5])?;
if kdf_id != material.kdf_id() {
return Err(ExportError::AuthFailed);
}
let mut salt = [0u8; SALT_LEN];
salt.copy_from_slice(&bundle[6..6 + SALT_LEN]);
let nonce_start = 6 + SALT_LEN;
let nonce = XNonce::from_slice(&bundle[nonce_start..nonce_start + 24]);
let aad = &bundle[..HEADER_LEN];
let ciphertext = &bundle[HEADER_LEN..];
let key = derive_bundle_key(material, &salt)?;
let cipher = <XChaCha20Poly1305 as chacha20poly1305::KeyInit>::new(key.as_slice().into());
let plaintext = cipher
.decrypt(
nonce,
Payload {
msg: ciphertext,
aad,
},
)
.map(Zeroizing::new)
.map_err(|_| ExportError::AuthFailed)?;
let payload: BundlePayload = ciborium::de::from_reader(plaintext.as_slice())
.map_err(|e| ExportError::CborDe(e.to_string()))?;
Ok(payload)
}
pub fn export_bundle(
store: &SecretsStore,
scope: SecretScope<'_>,
material: &BundleKeyMaterial<'_>,
) -> Result<Vec<u8>, ExportError> {
let scope_label = match &scope {
SecretScope::Local => "local",
SecretScope::User { .. } => "user",
};
let entries = store.export_scope_entries(scope)?;
seal_bundle(&entries, scope_label, material)
}
pub fn import_bundle(
store: &mut SecretsStore,
bundle: &[u8],
material: &BundleKeyMaterial<'_>,
target_scope: &TargetScope,
overwrite: bool,
) -> Result<ImportReport, ExportError> {
let payload = open_bundle(bundle, material)?;
let mut report = ImportReport::default();
for entry in &payload.entries {
let delegate_bytes: [u8; 32] =
entry
.delegate_key
.as_slice()
.try_into()
.map_err(|_| ExportError::BadEntryField {
field: "delegate_key",
len: entry.delegate_key.len(),
expected: 32,
})?;
let code_hash_bytes: [u8; 32] =
entry
.code_hash
.as_slice()
.try_into()
.map_err(|_| ExportError::BadEntryField {
field: "code_hash",
len: entry.code_hash.len(),
expected: 32,
})?;
let secret_hash: [u8; 32] =
entry
.secret_hash
.as_slice()
.try_into()
.map_err(|_| ExportError::BadEntryField {
field: "secret_hash",
len: entry.secret_hash.len(),
expected: 32,
})?;
let delegate = DelegateKey::new(delegate_bytes, CodeHash::from(&code_hash_bytes));
let plaintext = Zeroizing::new(entry.plaintext.clone());
let scope = target_scope.as_scope();
let wrote = store
.import_secret_by_hash(&delegate, &secret_hash, scope, plaintext, overwrite)
.map_err(|e| ExportError::Runtime(e.to_string()))?;
if wrote {
report.imported += 1;
} else {
report.skipped.push((
delegate.encode(),
bs58::encode(secret_hash)
.with_alphabet(bs58::Alphabet::BITCOIN)
.into_string(),
));
}
}
Ok(report)
}
pub enum TargetScope {
Local,
User(UserSecretContext),
}
impl TargetScope {
pub fn user_from_token(token: &[u8]) -> Self {
TargetScope::User(UserSecretContext::from_token(token))
}
fn as_scope(&self) -> SecretScope<'_> {
match self {
TargetScope::Local => SecretScope::Local,
TargetScope::User(ctx) => ctx.scope(),
}
}
}
pub fn write_bundle_file(path: &std::path::Path, bundle: &[u8]) -> Result<(), ExportError> {
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(path)?;
f.write_all(bundle)?;
f.sync_all()?;
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
use crate::contract::storages::Storage;
use crate::wasm_runtime::secrets_store::UserSecretContext;
use freenet_stdlib::prelude::{Delegate, SecretsId};
async fn new_store(dir: &std::path::Path) -> SecretsStore {
let secrets_dir = dir.join("secrets");
std::fs::create_dir_all(&secrets_dir).unwrap();
let db = Storage::new(dir).await.expect("db");
SecretsStore::new(secrets_dir, Default::default(), db).expect("store")
}
fn delegate(code: u8) -> Delegate<'static> {
Delegate::from((&vec![code].into(), &vec![].into()))
}
fn put_user(
store: &mut SecretsStore,
d: &Delegate<'static>,
ctx: &UserSecretContext,
secret_id: &SecretsId,
plaintext: &[u8],
) -> [u8; 32] {
store
.store_secret(
d.key(),
secret_id,
ctx.scope(),
Zeroizing::new(plaintext.to_vec()),
)
.expect("store user secret");
*secret_id.hash()
}
#[tokio::test]
async fn user_scope_passphrase_round_trip() {
let tmp = tempfile::tempdir().unwrap();
let mut store = new_store(tmp.path()).await;
let token = b"user-token-abc";
let ctx = UserSecretContext::from_token(token);
let d1 = delegate(1);
let d2 = delegate(2);
let s1 = SecretsId::new(b"alpha".to_vec());
let s2 = SecretsId::new(b"beta".to_vec());
let s3 = SecretsId::new(b"gamma".to_vec());
let h1 = put_user(&mut store, &d1, &ctx, &s1, b"plain-1");
let h2 = put_user(&mut store, &d1, &ctx, &s2, b"plain-2");
let h3 = put_user(&mut store, &d2, &ctx, &s3, b"plain-3");
let other_ctx = UserSecretContext::from_token(b"other-user");
put_user(&mut store, &d1, &other_ctx, &s1, b"other-plain");
let pass = BundleKeyMaterial::Passphrase(b"correct horse battery staple");
let bundle = export_bundle(&store, ctx.scope(), &pass).expect("export");
assert!(!contains(&bundle, b"plain-1"));
assert!(!contains(&bundle, b"plain-2"));
assert!(!contains(&bundle, b"plain-3"));
let tmp2 = tempfile::tempdir().unwrap();
let mut fresh = new_store(tmp2.path()).await;
let report =
import_bundle(&mut fresh, &bundle, &pass, &TargetScope::Local, false).expect("import");
assert_eq!(report.imported, 3, "all three of this user's secrets land");
assert!(report.skipped.is_empty());
assert_eq!(read_local(&fresh, &d1, &h1), b"plain-1");
assert_eq!(read_local(&fresh, &d1, &h2), b"plain-2");
assert_eq!(read_local(&fresh, &d2, &h3), b"plain-3");
}
#[tokio::test]
async fn token_keyed_round_trip() {
let tmp = tempfile::tempdir().unwrap();
let mut store = new_store(tmp.path()).await;
let token = b"opaque-bearer-token";
let ctx = UserSecretContext::from_token(token);
let d = delegate(7);
let s = SecretsId::new(b"k".to_vec());
let h = put_user(&mut store, &d, &ctx, &s, b"secret-value");
let material = BundleKeyMaterial::Token(token);
let bundle = export_bundle(&store, ctx.scope(), &material).expect("export");
let tmp2 = tempfile::tempdir().unwrap();
let mut fresh = new_store(tmp2.path()).await;
let report = import_bundle(&mut fresh, &bundle, &material, &TargetScope::Local, false)
.expect("import");
assert_eq!(report.imported, 1);
assert_eq!(read_local(&fresh, &d, &h), b"secret-value");
}
#[tokio::test]
async fn local_scope_round_trip() {
let tmp = tempfile::tempdir().unwrap();
let mut store = new_store(tmp.path()).await;
let d = delegate(3);
let s = SecretsId::new(b"local-secret".to_vec());
store
.store_secret(
d.key(),
&s,
SecretScope::Local,
Zeroizing::new(b"loc".to_vec()),
)
.unwrap();
let h = *s.hash();
let pass = BundleKeyMaterial::Passphrase(b"pw");
let bundle = export_bundle(&store, SecretScope::Local, &pass).expect("export");
let tmp2 = tempfile::tempdir().unwrap();
let mut fresh = new_store(tmp2.path()).await;
let report =
import_bundle(&mut fresh, &bundle, &pass, &TargetScope::Local, false).expect("import");
assert_eq!(report.imported, 1);
assert_eq!(read_local(&fresh, &d, &h), b"loc");
}
#[tokio::test]
async fn wrong_passphrase_fails_clean_no_write() {
let tmp = tempfile::tempdir().unwrap();
let mut store = new_store(tmp.path()).await;
let d = delegate(4);
let s = SecretsId::new(b"x".to_vec());
store
.store_secret(
d.key(),
&s,
SecretScope::Local,
Zeroizing::new(b"v".to_vec()),
)
.unwrap();
let bundle = export_bundle(
&store,
SecretScope::Local,
&BundleKeyMaterial::Passphrase(b"right"),
)
.expect("export");
let tmp2 = tempfile::tempdir().unwrap();
let mut fresh = new_store(tmp2.path()).await;
let err = import_bundle(
&mut fresh,
&bundle,
&BundleKeyMaterial::Passphrase(b"wrong"),
&TargetScope::Local,
false,
)
.expect_err("wrong passphrase must fail");
assert!(matches!(err, ExportError::AuthFailed), "got {err:?}");
assert!(
fresh
.export_scope_entries(SecretScope::Local)
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn wrong_token_fails_clean() {
let tmp = tempfile::tempdir().unwrap();
let mut store = new_store(tmp.path()).await;
let ctx = UserSecretContext::from_token(b"tok-a");
let d = delegate(5);
let s = SecretsId::new(b"y".to_vec());
put_user(&mut store, &d, &ctx, &s, b"v");
let bundle = export_bundle(&store, ctx.scope(), &BundleKeyMaterial::Token(b"tok-a"))
.expect("export");
let tmp2 = tempfile::tempdir().unwrap();
let mut fresh = new_store(tmp2.path()).await;
let err = import_bundle(
&mut fresh,
&bundle,
&BundleKeyMaterial::Token(b"tok-b"),
&TargetScope::Local,
false,
)
.expect_err("wrong token must fail");
assert!(matches!(err, ExportError::AuthFailed), "got {err:?}");
}
#[tokio::test]
async fn passphrase_bundle_rejects_token_material() {
let tmp = tempfile::tempdir().unwrap();
let mut store = new_store(tmp.path()).await;
let d = delegate(6);
let s = SecretsId::new(b"z".to_vec());
store
.store_secret(
d.key(),
&s,
SecretScope::Local,
Zeroizing::new(b"v".to_vec()),
)
.unwrap();
let bundle = export_bundle(
&store,
SecretScope::Local,
&BundleKeyMaterial::Passphrase(b"pw"),
)
.expect("export");
let tmp2 = tempfile::tempdir().unwrap();
let mut fresh = new_store(tmp2.path()).await;
let err = import_bundle(
&mut fresh,
&bundle,
&BundleKeyMaterial::Token(b"pw"),
&TargetScope::Local,
false,
)
.expect_err("token material on a passphrase bundle must fail");
assert!(matches!(err, ExportError::AuthFailed), "got {err:?}");
}
#[tokio::test]
async fn collision_without_overwrite_skips_and_reports() {
let tmp = tempfile::tempdir().unwrap();
let mut store = new_store(tmp.path()).await;
let d = delegate(8);
let s = SecretsId::new(b"dup".to_vec());
store
.store_secret(
d.key(),
&s,
SecretScope::Local,
Zeroizing::new(b"orig".to_vec()),
)
.unwrap();
let h = *s.hash();
let pass = BundleKeyMaterial::Passphrase(b"pw");
let bundle = export_bundle(&store, SecretScope::Local, &pass).expect("export");
let tmp2 = tempfile::tempdir().unwrap();
let mut target = new_store(tmp2.path()).await;
target
.store_secret(
d.key(),
&s,
SecretScope::Local,
Zeroizing::new(b"existing".to_vec()),
)
.unwrap();
let report =
import_bundle(&mut target, &bundle, &pass, &TargetScope::Local, false).expect("import");
assert_eq!(report.imported, 0);
assert_eq!(report.skipped.len(), 1);
assert_eq!(read_local(&target, &d, &h), b"existing");
let report =
import_bundle(&mut target, &bundle, &pass, &TargetScope::Local, true).expect("import");
assert_eq!(report.imported, 1);
assert!(report.skipped.is_empty());
assert_eq!(read_local(&target, &d, &h), b"orig");
}
#[tokio::test]
async fn user_scope_import_round_trip() {
let tmp = tempfile::tempdir().unwrap();
let mut store = new_store(tmp.path()).await;
let token = b"round-trip-user";
let ctx = UserSecretContext::from_token(token);
let d = delegate(9);
let s = SecretsId::new(b"u".to_vec());
let h = put_user(&mut store, &d, &ctx, &s, b"uservalue");
let material = BundleKeyMaterial::Token(token);
let bundle = export_bundle(&store, ctx.scope(), &material).expect("export");
let tmp2 = tempfile::tempdir().unwrap();
let mut fresh = new_store(tmp2.path()).await;
let target = TargetScope::user_from_token(token);
let report = import_bundle(&mut fresh, &bundle, &material, &target, false).expect("import");
assert_eq!(report.imported, 1);
let read = fresh
.get_secret(d.key(), &s, ctx.scope())
.expect("read user secret");
assert_eq!(read.to_vec(), b"uservalue");
let entries = fresh.export_scope_entries(ctx.scope()).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].secret_hash, h);
}
#[tokio::test]
async fn bad_magic_and_truncation_rejected() {
let pass = BundleKeyMaterial::Passphrase(b"pw");
match open_bundle(
b"not-a-bundle-but-long-enough-header-xxxxxxxxxxxxxxxx",
&pass,
) {
Err(ExportError::BadMagic) => {}
other => panic!("expected BadMagic, got {:?}", other.err()),
}
match open_bundle(b"short", &pass) {
Err(ExportError::Truncated(_)) => {}
other => panic!("expected Truncated, got {:?}", other.err()),
}
}
#[tokio::test]
async fn empty_scope_exports_empty_bundle() {
let tmp = tempfile::tempdir().unwrap();
let store = new_store(tmp.path()).await;
let pass = BundleKeyMaterial::Passphrase(b"pw");
let bundle = export_bundle(&store, SecretScope::Local, &pass).expect("export empty");
let payload = open_bundle(&bundle, &pass).expect("open empty");
assert!(payload.entries.is_empty());
let tmp2 = tempfile::tempdir().unwrap();
let mut fresh = new_store(tmp2.path()).await;
let report =
import_bundle(&mut fresh, &bundle, &pass, &TargetScope::Local, false).expect("import");
assert_eq!(report, ImportReport::default());
}
#[tokio::test]
async fn tampering_with_header_or_ciphertext_fails_auth() {
let tmp = tempfile::tempdir().unwrap();
let mut store = new_store(tmp.path()).await;
let d = delegate(11);
let s = SecretsId::new(b"tamper".to_vec());
store
.store_secret(
d.key(),
&s,
SecretScope::Local,
Zeroizing::new(b"sensitive".to_vec()),
)
.unwrap();
let pass = BundleKeyMaterial::Passphrase(b"pw");
let bundle = export_bundle(&store, SecretScope::Local, &pass).expect("export");
open_bundle(&bundle, &pass).expect("untampered bundle must open");
let mut header_tampered = bundle.clone();
header_tampered[6] ^= 0x01;
match open_bundle(&header_tampered, &pass) {
Err(ExportError::AuthFailed) => {}
other => panic!("header tamper must fail auth, got {:?}", other.err()),
}
let mut body_tampered = bundle.clone();
let last = body_tampered.len() - 1;
body_tampered[last] ^= 0x01;
match open_bundle(&body_tampered, &pass) {
Err(ExportError::AuthFailed) => {}
other => panic!("ciphertext tamper must fail auth, got {:?}", other.err()),
}
}
fn read_local(store: &SecretsStore, d: &Delegate<'static>, hash: &[u8; 32]) -> Vec<u8> {
let entries = store.export_scope_entries(SecretScope::Local).unwrap();
entries
.into_iter()
.find(|e| &e.secret_hash == hash && e.delegate_key == *d.key())
.map(|e| e.plaintext.to_vec())
.expect("secret present at Local scope")
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|w| w == needle)
}
}