use hkdf::Hkdf;
use hmac::{Hmac, KeyInit, Mac};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::Sha256;
use vta_sdk::sealed_transfer::{SealedPayloadV1, armor, multibase_digest_to_hex, open_bundle};
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use zeroize::Zeroizing;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SealedRefusal {
CannotOpen(String),
Invalid(String),
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StoredCredential {
pub cleartext: Value,
pub fingerprint: String,
pub set_at: String,
}
impl std::fmt::Debug for StoredCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StoredCredential")
.field("fingerprint", &self.fingerprint)
.field("set_at", &self.set_at)
.finish_non_exhaustive()
}
}
pub struct OpenedCredential {
cleartext: Value,
}
impl OpenedCredential {
pub fn kind(&self) -> &str {
self.cleartext["kind"].as_str().unwrap_or_default()
}
}
fn key_of(config_id: &str) -> String {
format!("cred:{config_id}")
}
pub struct SealedCredentials {
ks: KeyspaceHandle,
open_key: Zeroizing<[u8; 32]>,
fingerprint_key: Zeroizing<[u8; 32]>,
}
impl SealedCredentials {
pub fn new(ks: KeyspaceHandle, storage_key: [u8; 32], ed25519_seed: &[u8; 32]) -> Self {
let open_key = vta_sdk::sealed_transfer::ed25519_seed_to_x25519_secret(ed25519_seed);
let mut fingerprint_key = Zeroizing::new([0u8; 32]);
Hkdf::<Sha256>::new(None, ed25519_seed)
.expand(
b"vtc-storage-credential-fingerprint/v1",
&mut fingerprint_key[..],
)
.expect("32 bytes of HKDF-SHA256 output");
Self {
ks: ks.with_encryption(storage_key),
open_key,
fingerprint_key,
}
}
pub fn open(
&self,
config_id: &str,
kind: &str,
armored: &str,
digest_multibase: &str,
) -> Result<OpenedCredential, SealedRefusal> {
let cannot = |why: &str| SealedRefusal::CannotOpen(why.to_string());
let bundles = armor::decode(armored).map_err(|_| cannot("the block is not armored"))?;
let [bundle] = bundles.as_slice() else {
return Err(cannot("the block holds other than one sealed bundle"));
};
let digest = multibase_digest_to_hex(digest_multibase)
.map_err(|_| cannot("digestMultibase is not a sha2-256 digest"))?;
let opened = open_bundle(&self.open_key, bundle, Some(&digest)).map_err(|e| {
cannot(match e {
vta_sdk::sealed_transfer::SealedTransferError::DigestMismatch { .. } => {
"the block's digest is not digestMultibase"
}
_ => "the block is not sealed to this community",
})
})?;
let cleartext = match opened.payload {
SealedPayloadV1::StorageCredential(p) => p.0,
_ => {
return Err(SealedRefusal::Invalid(
"the block holds something other than a storage credential".into(),
));
}
};
check_cleartext(&cleartext, config_id, kind)?;
Ok(OpenedCredential { cleartext })
}
fn fingerprint(&self, cleartext: &Value) -> String {
let canonical = serde_json_canonicalizer::to_vec(cleartext).unwrap_or_default();
let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(&self.fingerprint_key[..])
.expect("HMAC takes any key");
mac.update(&canonical);
let mut mh = vec![0x12, 0x20];
mh.extend_from_slice(&mac.finalize().into_bytes());
multibase::encode(multibase::Base::Base58Btc, mh)
}
pub async fn put(
&self,
config_id: &str,
opened: OpenedCredential,
) -> Result<StoredCredential, AppError> {
let stored = StoredCredential {
fingerprint: self.fingerprint(&opened.cleartext),
set_at: chrono::Utc::now().to_rfc3339(),
cleartext: opened.cleartext,
};
self.ks.remove(key_of(config_id)).await?;
self.ks.insert(key_of(config_id), &stored).await?;
Ok(stored)
}
pub async fn get(&self, config_id: &str) -> Result<Option<StoredCredential>, AppError> {
self.ks.get(key_of(config_id)).await
}
pub async fn remove(&self, config_id: &str) -> Result<(), AppError> {
self.ks.remove(key_of(config_id)).await
}
pub async fn clear(&self) -> Result<usize, AppError> {
let keys = self.ks.prefix_keys(b"cred:".to_vec()).await?;
for k in &keys {
self.ks.remove(k.clone()).await?;
}
Ok(keys.len())
}
}
fn cleartext_schema() -> Option<String> {
use trust_tasks_rs::Payload;
use trust_tasks_rs::specs::vtc::storage::credentials::set::v0_2 as set;
let mut schema: Value = serde_json::from_str(set::Payload::PAYLOAD_SCHEMA?).ok()?;
let defs = schema.get("$defs")?.as_object()?;
let name = ["SealedCredentialCleartext", "SealedStorageCredential"]
.into_iter()
.find(|n| defs.contains_key(*n))?;
let obj = schema.as_object_mut()?;
for member in [
"properties",
"required",
"additionalProperties",
"type",
"title",
"description",
] {
obj.remove(member);
}
obj.insert("$ref".into(), Value::String(format!("#/$defs/{name}")));
serde_json::to_string(&schema).ok()
}
pub fn check_cleartext(
cleartext: &Value,
config_id: &str,
kind: &str,
) -> Result<(), SealedRefusal> {
let invalid = |why: &str| SealedRefusal::Invalid(why.to_string());
let schema = cleartext_schema()
.ok_or_else(|| invalid("this build cannot read the SealedStorageCredential schema"))?;
if trust_tasks_rs::validate::against_schema(&schema, cleartext).is_err() {
return Err(invalid(
"the cleartext does not validate as a SealedStorageCredential",
));
}
if cleartext["kind"].as_str() != Some(kind) {
return Err(invalid("the credential's kind is not the config's"));
}
if cleartext["configId"].as_str() != Some(config_id) {
return Err(invalid("the credential names another config"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use vta_sdk::sealed_transfer::{
AssertionProof, InMemoryNonceStore, ProducerAssertion, StorageCredentialPayload,
bundle_digest_multibase, seal_payload,
};
const SEED: [u8; 32] = [7; 32];
fn community_x25519_pub() -> [u8; 32] {
let secret = vta_sdk::sealed_transfer::ed25519_seed_to_x25519_secret(&SEED);
x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(*secret)).to_bytes()
}
async fn seal_to(
recipient: &[u8; 32],
cleartext: Value,
) -> vta_sdk::sealed_transfer::SealedBundle {
let mut id = [0u8; 16];
rand::fill(&mut id);
seal_payload(
recipient,
id,
ProducerAssertion {
producer_did: "did:key:zAdmin".into(),
proof: AssertionProof::PinnedOnly,
},
&SealedPayloadV1::StorageCredential(Box::new(StorageCredentialPayload(cleartext))),
&InMemoryNonceStore::default(),
)
.await
.unwrap()
}
async fn seal(cleartext: Value) -> (String, String) {
let bundle = seal_to(&community_x25519_pub(), cleartext).await;
(armor::encode(&bundle), bundle_digest_multibase(&bundle))
}
async fn store(dir: &std::path::Path) -> SealedCredentials {
let s = vti_common::store::test_fixture::open_with_keyspaces(
&dir.join("db"),
&["storage_credentials"],
)
.unwrap();
SealedCredentials::new(s.keyspace("storage_credentials").unwrap(), [3; 32], &SEED)
}
fn s3(config: &str) -> Value {
json!({
"kind": "s3",
"configId": config,
"accessKeyId": "EXAMPLEKEYID0001",
"secretAccessKey": "example-secret-not-a-real-one",
})
}
#[tokio::test]
async fn blocks_the_console_seals_in_the_browser_open_here() {
const VECTORS: &str = include_str!("../../admin-ui/src/lib/sealed-transfer.vectors.json");
let v: Value = serde_json::from_str(VECTORS).expect("sealed-transfer.vectors.json parses");
let published = vta_sdk::did_key::decode_ed25519_public_key_multibase(
v["communityPublicKeyMultibase"].as_str().unwrap(),
)
.unwrap();
assert_eq!(
published,
ed25519_dalek::SigningKey::from_bytes(&SEED)
.verifying_key()
.to_bytes()
);
assert_eq!(
v["communityX25519Hex"].as_str().unwrap(),
hex::encode(community_x25519_pub())
);
let dir = tempfile::tempdir().unwrap();
let creds = store(dir.path()).await;
let cases = v["cases"].as_array().unwrap();
assert!(cases.len() >= 3);
for case in cases {
let name = case["name"].as_str().unwrap();
let opened = creds
.open(
case["configId"].as_str().unwrap(),
case["kind"].as_str().unwrap(),
case["sealed"].as_str().unwrap(),
case["digestMultibase"].as_str().unwrap(),
)
.unwrap_or_else(|e| panic!("{name}: {e:?}"));
assert_eq!(opened.cleartext, case["cleartext"], "{name}");
}
let first = &cases[0];
assert!(matches!(
creds.open(
"another-config",
first["kind"].as_str().unwrap(),
first["sealed"].as_str().unwrap(),
first["digestMultibase"].as_str().unwrap(),
),
Err(SealedRefusal::Invalid(_))
));
}
#[tokio::test]
async fn a_sealed_credential_round_trips_and_is_kept_encrypted() {
let dir = tempfile::tempdir().unwrap();
let creds = store(dir.path()).await;
let (armored, digest) = seal(s3("r2-archive")).await;
let opened = creds.open("r2-archive", "s3", &armored, &digest).unwrap();
let stored = creds.put("r2-archive", opened).await.unwrap();
assert!(stored.fingerprint.starts_with('z'));
assert!(!format!("{stored:?}").contains("example-secret"));
let back = creds.get("r2-archive").await.unwrap().unwrap();
assert_eq!(back.cleartext, s3("r2-archive"));
let raw = vti_common::store::test_fixture::open_with_keyspaces(
&dir.path().join("db"),
&["storage_credentials"],
);
drop(raw);
for entry in walk(&dir.path().join("db")) {
let bytes = std::fs::read(&entry).unwrap_or_default();
assert!(
!bytes
.windows(b"example-secret".len())
.any(|w| w == b"example-secret"),
"{} holds the secret in the clear",
entry.display()
);
}
let mut other = s3("r2-archive");
other["secretAccessKey"] = json!("another-secret");
let (armored, digest) = seal(other).await;
let second = creds
.put(
"r2-archive",
creds.open("r2-archive", "s3", &armored, &digest).unwrap(),
)
.await
.unwrap();
assert_ne!(second.fingerprint, stored.fingerprint);
assert_eq!(creds.clear().await.unwrap(), 1);
assert!(creds.get("r2-archive").await.unwrap().is_none());
}
fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
if let Ok(rd) = std::fs::read_dir(dir) {
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
out.extend(walk(&p));
} else {
out.push(p);
}
}
}
out
}
#[tokio::test]
async fn a_block_for_another_config_kind_or_digest_is_refused() {
let dir = tempfile::tempdir().unwrap();
let creds = store(dir.path()).await;
let (armored, digest) = seal(s3("r2-archive")).await;
assert!(matches!(
creds.open("elsewhere", "s3", &armored, &digest),
Err(SealedRefusal::Invalid(_))
));
assert!(matches!(
creds.open("r2-archive", "gcs", &armored, &digest),
Err(SealedRefusal::Invalid(_))
));
let (_, other_digest) = seal(s3("x")).await;
assert!(matches!(
creds.open("r2-archive", "s3", &armored, &other_digest),
Err(SealedRefusal::CannotOpen(_))
));
assert!(matches!(
creds.open("r2-archive", "s3", "not armor", &digest),
Err(SealedRefusal::CannotOpen(_))
));
let mut extra = s3("r2-archive");
extra["note"] = json!("x");
let (armored, digest) = seal(extra).await;
let refused = creds.open("r2-archive", "s3", &armored, &digest);
match refused {
Err(SealedRefusal::Invalid(why)) => assert!(!why.contains("example-secret")),
other => panic!("{:?}", other.err()),
}
let other_pub =
x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([1u8; 32])).to_bytes();
let bundle = seal_to(&other_pub, s3("r2-archive")).await;
assert!(matches!(
creds.open(
"r2-archive",
"s3",
&armor::encode(&bundle),
&bundle_digest_multibase(&bundle)
),
Err(SealedRefusal::CannotOpen(_))
));
}
#[test]
fn a_gcs_key_file_and_a_walrus_key_validate() {
let gcs = json!({
"kind": "gcs",
"configId": "g",
"serviceAccountKey": {
"type": "service_account",
"client_email": "rooms@p.iam.gserviceaccount.com",
"private_key_id": "abc123",
"private_key": format!("-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n", "A".repeat(64)),
"project_id": "p",
"token_uri": "https://evil.example/token",
},
});
check_cleartext(&gcs, "g", "gcs").unwrap();
let walrus = json!({ "kind": "walrus", "configId": "w", "suiPrivateKey": "suiprivkey1qqqqqqqqqqqqqqqqqqqqqqqq" });
check_cleartext(&walrus, "w", "walrus").unwrap();
}
}