use aes_gcm::aead::Aead;
use aes_gcm::{Aes256Gcm, KeyInit};
use hkdf::Hkdf;
use sha2::Sha256;
use zeroize::Zeroize;
use zeroize::Zeroizing;
use crate::store::{KeyspaceHandle, Store};
pub const MIGRATION_EXCLUDED_KEYSPACES: &[&str] = &[vta_keyspaces::BOOTSTRAP];
pub const HARDENED_JWT_KEY: &str = "hardened:jwt_key";
pub const LEGACY_JWT_CT_KEY: &str = "hardened:jwt_ciphertext";
pub const LEGACY_JWT_FINGERPRINT_KEY: &str = "hardened:jwt_fingerprint";
const STORAGE_KEY_INFO: &[u8] = b"vta-storage-key/v1";
pub fn derive_storage_key(seed: &[u8], salt: &str) -> Zeroizing<[u8; 32]> {
let mut key = [0u8; 32];
Hkdf::<Sha256>::new(Some(salt.as_bytes()), seed)
.expand(STORAGE_KEY_INFO, &mut key)
.expect("32-byte output is within HKDF-SHA256 limits");
Zeroizing::new(key)
}
pub fn generate_storage_key_salt() -> String {
let mut bytes = [0u8; 16];
rand::fill(&mut bytes);
hex::encode(bytes)
}
pub fn legacy_aes_gcm_open(key: &[u8; 32], blob: &[u8]) -> Option<Vec<u8>> {
if blob.len() < 13 {
return None;
}
let nonce = aes_gcm::Nonce::from_slice(&blob[..12]);
let cipher = Aes256Gcm::new_from_slice(key).ok()?;
cipher.decrypt(nonce, &blob[12..]).ok()
}
#[derive(Debug)]
pub enum JwtKeyError {
BadKeyLength,
LegacyDecryptFailed,
Store(vti_common::error::AppError),
}
impl std::fmt::Display for JwtKeyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
JwtKeyError::BadKeyLength => write!(
f,
"hardened: stored JWT signing key is not 32 bytes — the row is corrupt. \
Run `vta hardened rotate-jwt` to generate a new one (existing sessions \
will be invalidated)."
),
JwtKeyError::LegacyDecryptFailed => write!(
f,
"hardened: the legacy sealed JWT signing key would not decrypt — \
storage_key_salt mismatch or tampering. Run `vta hardened rotate-jwt` \
to discard it and generate a new key (existing sessions will be \
invalidated)."
),
JwtKeyError::Store(e) => write!(
f,
"hardened: could not read the JWT signing key: {e}. If this is a decrypt \
failure, the storage_key_salt or the master seed does not match the one \
this store was written with."
),
}
}
}
impl From<vti_common::error::AppError> for JwtKeyError {
fn from(e: vti_common::error::AppError) -> Self {
JwtKeyError::Store(e)
}
}
pub async fn load_or_generate_jwt_key(
keys_ks: &KeyspaceHandle,
bootstrap_ks: &KeyspaceHandle,
storage_key: &[u8; 32],
) -> Result<[u8; 32], JwtKeyError> {
if let Some(bytes) = keys_ks.get_raw(HARDENED_JWT_KEY).await? {
return bytes.try_into().map_err(|_| JwtKeyError::BadKeyLength);
}
if let Some(blob) = bootstrap_ks.get_raw(LEGACY_JWT_CT_KEY).await? {
let plaintext =
legacy_aes_gcm_open(storage_key, &blob).ok_or(JwtKeyError::LegacyDecryptFailed)?;
let key: [u8; 32] = plaintext
.try_into()
.map_err(|_| JwtKeyError::BadKeyLength)?;
keys_ks.insert_raw(HARDENED_JWT_KEY, key.to_vec()).await?;
bootstrap_ks.remove(LEGACY_JWT_CT_KEY).await?;
bootstrap_ks.remove(LEGACY_JWT_FINGERPRINT_KEY).await?;
tracing::info!(
"hardened: moved the JWT signing key into the encrypted KEYS keyspace; \
the bespoke seal and its fingerprint row are gone. The key is unchanged, \
so existing sessions remain valid"
);
return Ok(key);
}
let mut key = [0u8; 32];
rand::fill(&mut key);
keys_ks.insert_raw(HARDENED_JWT_KEY, key.to_vec()).await?;
Ok(key)
}
pub async fn migrate_store_to_encrypted(
store: &Store,
key: [u8; 32],
) -> Result<usize, vti_common::error::AppError> {
let mut total = 0usize;
for name in vta_keyspaces::ALL {
if MIGRATION_EXCLUDED_KEYSPACES.contains(name) {
continue;
}
let bare = store.keyspace(name)?;
let migrated = bare.migrate_to_encrypted(key).await?;
if migrated > 0 {
tracing::info!(
keyspace = %name,
rows = migrated,
"hardened: converted plaintext rows to encrypted storage"
);
total += migrated;
}
}
if total > 0 {
store.persist().await?;
tracing::warn!(
rows = total,
"hardened: migrated a pre-existing plaintext store to encrypted at rest — \
this is a one-time conversion; take a backup if you have not already"
);
}
Ok(total)
}
pub struct HardenedBootSecrets {
pub storage_key: [u8; 32],
pub jwt_key: [u8; 32],
}
impl Drop for HardenedBootSecrets {
fn drop(&mut self) {
self.storage_key.zeroize();
self.jwt_key.zeroize();
}
}
pub async fn load_boot_secrets(
config: &crate::config::AppConfig,
seed_store: &dyn crate::keys::seed_store::SeedStore,
store: &Store,
) -> Result<HardenedBootSecrets, Box<dyn std::error::Error>> {
let seed = zeroize::Zeroizing::new(
seed_store
.get()
.await
.map_err(|e| format!("hardened: seed load failed: {e}"))?
.ok_or("hardened: no seed in secret store — run `vta setup` first")?,
);
let storage_key = *derive_storage_key(&seed, &config.hardened.storage_key_salt);
drop(seed);
migrate_store_to_encrypted(store, storage_key)
.await
.map_err(|e| format!("hardened: store migration failed: {e}"))?;
let keys_ks = store
.keyspace(vta_keyspaces::KEYS)
.map_err(|e| format!("hardened: open KEYS keyspace: {e}"))?
.with_encryption(storage_key);
let bootstrap_ks = store
.keyspace(vta_keyspaces::BOOTSTRAP)
.map_err(|e| format!("hardened: open BOOTSTRAP keyspace: {e}"))?;
let existed = keys_ks
.get_raw(HARDENED_JWT_KEY)
.await
.ok()
.flatten()
.is_some();
let jwt_key = load_or_generate_jwt_key(&keys_ks, &bootstrap_ks, &storage_key)
.await
.map_err(|e| format!("{e}"))?;
if existed {
tracing::info!("hardened: JWT signing key loaded from the encrypted KEYS keyspace");
} else {
tracing::info!(
"hardened: JWT signing key established in the encrypted KEYS keyspace \
(generated, or carried over from the legacy sealed row)"
);
}
Ok(HardenedBootSecrets {
storage_key,
jwt_key,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derive_storage_key_is_deterministic() {
let seed = [0x42u8; 32];
let k1 = derive_storage_key(&seed, "test-salt");
let k2 = derive_storage_key(&seed, "test-salt");
assert_eq!(*k1, *k2);
}
#[test]
fn derive_storage_key_differs_by_salt() {
let seed = [0x42u8; 32];
let k1 = derive_storage_key(&seed, "salt-a");
let k2 = derive_storage_key(&seed, "salt-b");
assert_ne!(*k1, *k2);
}
#[test]
fn derive_storage_key_differs_by_seed() {
let k1 = derive_storage_key(&[0x01u8; 32], "same-salt");
let k2 = derive_storage_key(&[0x02u8; 32], "same-salt");
assert_ne!(*k1, *k2);
}
#[test]
fn derive_storage_key_matches_known_test_vector() {
let seed = [0x42u8; 32];
let key = derive_storage_key(&seed, "test-salt");
assert_eq!(
hex::encode(*key),
"4d2652108d380a68af082f04e031d6c0dd67d3f86692fa79783aff92a0e9df4e",
"HKDF output changed — this would silently invalidate all encrypted fjall data"
);
}
fn legacy_aes_gcm_seal(key: &[u8; 32], plaintext: &[u8]) -> Vec<u8> {
use aes_gcm::aead::rand_core::RngCore;
let cipher = Aes256Gcm::new_from_slice(key).expect("32-byte key");
let mut nonce_bytes = [0u8; 12];
aes_gcm::aead::OsRng.fill_bytes(&mut nonce_bytes);
let nonce = aes_gcm::Nonce::from_slice(&nonce_bytes);
let mut ct = cipher.encrypt(nonce, plaintext).expect("AES-GCM encrypt");
let mut out = Vec::with_capacity(12 + ct.len());
out.extend_from_slice(&nonce_bytes);
out.append(&mut ct);
out
}
fn jwt_handles(
store: &crate::store::Store,
storage_key: [u8; 32],
) -> (KeyspaceHandle, KeyspaceHandle) {
let keys = store
.keyspace(crate::keyspaces::KEYS)
.expect("keys keyspace")
.with_encryption(storage_key);
let bootstrap = store
.keyspace(crate::keyspaces::BOOTSTRAP)
.expect("bootstrap keyspace");
(keys, bootstrap)
}
fn temp_bootstrap_ks() -> (crate::store::Store, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let config = vti_common::config::StoreConfig {
data_dir: dir.path().to_path_buf(),
};
let store = crate::store::Store::open(&config).expect("open store");
(store, dir)
}
#[tokio::test]
async fn first_boot_stores_key_in_the_encrypted_keyspace() {
let (store, _dir) = temp_bootstrap_ks();
let storage_key = [0x42u8; 32];
let (keys_ks, bs_ks) = jwt_handles(&store, storage_key);
let jwt_key = load_or_generate_jwt_key(&keys_ks, &bs_ks, &storage_key)
.await
.expect("first boot should succeed");
assert_eq!(jwt_key.len(), 32);
let stored = keys_ks
.get_raw(HARDENED_JWT_KEY)
.await
.unwrap()
.expect("row written");
assert_eq!(stored, jwt_key.to_vec());
let raw = store
.keyspace(crate::keyspaces::KEYS)
.unwrap()
.get_raw(HARDENED_JWT_KEY)
.await
.unwrap()
.expect("row present on disk");
assert!(
raw.starts_with(b"VAE1"),
"expected a VAE1 envelope on disk, got {:?}",
&raw[..raw.len().min(8)]
);
assert_ne!(raw, jwt_key.to_vec(), "key must not be stored in the clear");
assert!(bs_ks.get_raw(LEGACY_JWT_CT_KEY).await.unwrap().is_none());
assert!(
bs_ks
.get_raw(LEGACY_JWT_FINGERPRINT_KEY)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn subsequent_boot_returns_same_key() {
let (store, _dir) = temp_bootstrap_ks();
let storage_key = [0x77u8; 32];
let (keys_ks, bs_ks) = jwt_handles(&store, storage_key);
let first = load_or_generate_jwt_key(&keys_ks, &bs_ks, &storage_key)
.await
.expect("first boot");
let second = load_or_generate_jwt_key(&keys_ks, &bs_ks, &storage_key)
.await
.expect("subsequent boot");
assert_eq!(first, second);
}
#[tokio::test]
async fn wrong_storage_key_cannot_read_the_jwt_key() {
let (store, _dir) = temp_bootstrap_ks();
let (keys_a, bs_a) = jwt_handles(&store, [0x11u8; 32]);
load_or_generate_jwt_key(&keys_a, &bs_a, &[0x11u8; 32])
.await
.expect("first boot with key_a");
let (keys_b, bs_b) = jwt_handles(&store, [0x22u8; 32]);
let err = load_or_generate_jwt_key(&keys_b, &bs_b, &[0x22u8; 32])
.await
.expect_err("must not open under a different storage key");
assert!(
matches!(err, JwtKeyError::Store(_)),
"expected a decrypt failure from the store layer, got: {err}"
);
}
#[tokio::test]
async fn jwt_key_row_is_bound_to_its_location() {
let (store, _dir) = temp_bootstrap_ks();
let storage_key = [0x64u8; 32];
let (keys_ks, bs_ks) = jwt_handles(&store, storage_key);
load_or_generate_jwt_key(&keys_ks, &bs_ks, &storage_key)
.await
.expect("first boot");
let bare = store.keyspace(crate::keyspaces::KEYS).unwrap();
let envelope = bare.get_raw(HARDENED_JWT_KEY).await.unwrap().unwrap();
bare.insert_raw("hardened:jwt_key_copy", envelope)
.await
.expect("cut-and-paste the envelope to another row");
assert!(
keys_ks.get_raw("hardened:jwt_key_copy").await.is_err(),
"a relocated envelope must not decrypt — that is the AAD binding"
);
}
#[tokio::test]
async fn legacy_sealed_key_is_migrated_not_rotated() {
let (store, _dir) = temp_bootstrap_ks();
let storage_key = [0x5Au8; 32];
let (keys_ks, bs_ks) = jwt_handles(&store, storage_key);
let original = [0xC3u8; 32];
bs_ks
.insert_raw(
LEGACY_JWT_CT_KEY,
legacy_aes_gcm_seal(&storage_key, &original),
)
.await
.expect("write legacy ciphertext");
bs_ks
.insert_raw(
LEGACY_JWT_FINGERPRINT_KEY,
b"deadbeefdeadbeefdeadbeefdeadbeef".to_vec(),
)
.await
.expect("write legacy fingerprint");
let loaded = load_or_generate_jwt_key(&keys_ks, &bs_ks, &storage_key)
.await
.expect("legacy import");
assert_eq!(
loaded, original,
"the key must be carried across, not regenerated — otherwise the move \
silently invalidates every session"
);
assert_eq!(
keys_ks.get_raw(HARDENED_JWT_KEY).await.unwrap().unwrap(),
original.to_vec()
);
assert!(
bs_ks.get_raw(LEGACY_JWT_CT_KEY).await.unwrap().is_none(),
"legacy ciphertext must be removed"
);
assert!(
bs_ks
.get_raw(LEGACY_JWT_FINGERPRINT_KEY)
.await
.unwrap()
.is_none(),
"legacy fingerprint must be removed"
);
let again = load_or_generate_jwt_key(&keys_ks, &bs_ks, &storage_key)
.await
.expect("boot after import");
assert_eq!(loaded, again);
}
#[tokio::test]
async fn undecryptable_legacy_row_errors() {
let (store, _dir) = temp_bootstrap_ks();
let storage_key = [0x6Bu8; 32];
let (keys_ks, bs_ks) = jwt_handles(&store, storage_key);
bs_ks
.insert_raw(
LEGACY_JWT_CT_KEY,
legacy_aes_gcm_seal(&[0xFFu8; 32], &[0x01u8; 32]),
)
.await
.expect("write a legacy row sealed under a different key");
let err = load_or_generate_jwt_key(&keys_ks, &bs_ks, &storage_key)
.await
.expect_err("must not silently regenerate");
assert!(
matches!(err, JwtKeyError::LegacyDecryptFailed),
"expected LegacyDecryptFailed, got: {err}"
);
}
#[tokio::test]
async fn rotate_generates_new_key() {
let (store, _dir) = temp_bootstrap_ks();
let storage_key = [0x55u8; 32];
let (keys_ks, bs_ks) = jwt_handles(&store, storage_key);
let original = load_or_generate_jwt_key(&keys_ks, &bs_ks, &storage_key)
.await
.expect("first boot");
keys_ks.remove(HARDENED_JWT_KEY).await.expect("rotate");
let rotated = load_or_generate_jwt_key(&keys_ks, &bs_ks, &storage_key)
.await
.expect("boot after rotate");
assert_ne!(original, rotated, "rotate must produce a different key");
let third = load_or_generate_jwt_key(&keys_ks, &bs_ks, &storage_key)
.await
.expect("stable afterwards");
assert_eq!(rotated, third);
}
#[tokio::test]
async fn migration_makes_preexisting_plaintext_rows_readable() {
let (store, _dir) = temp_bootstrap_ks();
let key = [0x91u8; 32];
let bare_acl = store.keyspace(crate::keyspaces::ACL).expect("acl");
bare_acl
.insert_raw("did:key:zLegacy", b"legacy-plaintext".to_vec())
.await
.expect("write legacy row");
let enc_acl = store
.keyspace(crate::keyspaces::ACL)
.expect("acl")
.with_encryption(key);
assert!(
enc_acl.get_raw("did:key:zLegacy").await.is_err(),
"a legacy plaintext row must NOT be silently readable through an \
encrypted handle — that would be the downgrade hole"
);
let migrated = migrate_store_to_encrypted(&store, key)
.await
.expect("migration");
assert!(
migrated >= 1,
"expected at least the ACL row, got {migrated}"
);
let value = enc_acl
.get_raw("did:key:zLegacy")
.await
.expect("read after migration")
.expect("row present");
assert_eq!(value, b"legacy-plaintext");
}
#[tokio::test]
async fn migration_is_idempotent() {
let (store, _dir) = temp_bootstrap_ks();
let key = [0x92u8; 32];
store
.keyspace(crate::keyspaces::ACL)
.expect("acl")
.insert_raw("k", b"v".to_vec())
.await
.expect("seed row");
let first = migrate_store_to_encrypted(&store, key)
.await
.expect("first");
assert!(first >= 1);
let second = migrate_store_to_encrypted(&store, key)
.await
.expect("second");
assert_eq!(second, 0, "already-encrypted rows must be skipped");
let value = store
.keyspace(crate::keyspaces::ACL)
.expect("acl")
.with_encryption(key)
.get_raw("k")
.await
.expect("read")
.expect("present");
assert_eq!(value, b"v", "value must survive a repeated migration");
}
#[tokio::test]
async fn migration_leaves_bootstrap_keyspace_bare() {
let (store, _dir) = temp_bootstrap_ks();
let storage_key = [0x93u8; 32];
let (keys_ks, bs_ks) = jwt_handles(&store, storage_key);
bs_ks
.insert_raw("tee:did_log", b"{\"versionId\":\"1-abc\"}".to_vec())
.await
.expect("write parent-readable row");
let original = [0xD4u8; 32];
bs_ks
.insert_raw(
LEGACY_JWT_CT_KEY,
legacy_aes_gcm_seal(&storage_key, &original),
)
.await
.expect("write legacy ciphertext");
migrate_store_to_encrypted(&store, storage_key)
.await
.expect("migration");
assert_eq!(
bs_ks.get_raw("tee:did_log").await.unwrap().unwrap(),
b"{\"versionId\":\"1-abc\"}".to_vec(),
"migrating bootstrap would break the parent-side proxy"
);
let imported = load_or_generate_jwt_key(&keys_ks, &bs_ks, &storage_key)
.await
.expect("legacy import after migration");
assert_eq!(imported, original);
}
}