use serde::{Deserialize, Serialize};
use stack_profile::{ProfileData, ProfileError, ProfileStore};
use uuid::Uuid;
use vitaminc::protected::OpaqueDebug;
use zeroize::{Zeroize, ZeroizeOnDrop};
use zerokms_protocol::ViturKeyMaterial;
use super::{ClientKey, KeyProvider, KeyProviderError};
#[derive(Serialize, Deserialize, Zeroize, ZeroizeOnDrop, OpaqueDebug)]
pub struct SecretKey {
#[zeroize(skip)]
client_id: Uuid,
client_key: ViturKeyMaterial,
}
impl SecretKey {
pub fn new(client_id: Uuid, client_key: ViturKeyMaterial) -> Self {
Self {
client_id,
client_key,
}
}
pub fn from_hex(
client_id: String,
mut client_key_hex: String,
) -> Result<Self, KeyProviderError> {
let uuid = Uuid::parse_str(&client_id)
.map_err(|e| KeyProviderError::InvalidKey(format!("invalid client_id: {e}")))?;
let result = base16ct::mixed::decode_vec(&client_key_hex);
client_key_hex.zeroize();
let bytes = result
.map_err(|e| KeyProviderError::InvalidKey(format!("invalid client_key hex: {e}")))?;
Ok(Self::new(uuid, ViturKeyMaterial::from(bytes)))
}
pub fn from_env() -> Result<Option<Self>, KeyProviderError> {
use crate::config::vars::{CS_CLIENT_ID, CS_CLIENT_KEY};
match (std::env::var(CS_CLIENT_ID), std::env::var(CS_CLIENT_KEY)) {
(Ok(id), Ok(key)) => {
tracing::debug!("both {CS_CLIENT_ID} and {CS_CLIENT_KEY} set, loading secret key");
Self::from_hex(id, key).map(Some)
}
(Ok(_), Err(_)) => {
tracing::debug!("{CS_CLIENT_ID} set but {CS_CLIENT_KEY} missing, skipping");
Ok(None)
}
(Err(_), Ok(_)) => {
tracing::debug!("{CS_CLIENT_KEY} set but {CS_CLIENT_ID} missing, skipping");
Ok(None)
}
(Err(_), Err(_)) => {
tracing::debug!("neither {CS_CLIENT_ID} nor {CS_CLIENT_KEY} set");
Ok(None)
}
}
}
}
impl ProfileData for SecretKey {
const FILENAME: &'static str = "secretkey.json";
const MODE: Option<u32> = Some(0o600);
}
impl KeyProvider for SecretKey {
async fn client_key(&self) -> Result<ClientKey, KeyProviderError> {
ClientKey::from_bytes(self.client_id, &self.client_key)
.map_err(|e| KeyProviderError::InvalidKey(e.to_string()))
}
}
impl KeyProvider for ProfileStore {
async fn client_key(&self) -> Result<ClientKey, KeyProviderError> {
let ws_store = self.current_workspace_store().map_err(|e| match e {
ProfileError::NoCurrentWorkspace => KeyProviderError::NotConfigured(e.to_string()),
_ => KeyProviderError::LoadError(e.to_string()),
})?;
let secret_key: SecretKey = ws_store.load_profile().map_err(|e| match e {
ProfileError::NotFound { .. } => KeyProviderError::NotConfigured(e.to_string()),
_ => KeyProviderError::LoadError(e.to_string()),
})?;
secret_key.client_key().await
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use recipher::keyset::{EncryptionKeySet, ProxyKeySet};
use tempfile::TempDir;
fn random_secret_key() -> (SecretKey, Uuid, Vec<u8>) {
let client_id = Uuid::new_v4();
let ek_a = EncryptionKeySet::generate().unwrap();
let ek_b = EncryptionKeySet::generate().unwrap();
let keyset = ProxyKeySet::generate(&ek_a, &ek_b);
let bytes = keyset.to_bytes().unwrap();
let secret_key = SecretKey::new(client_id, ViturKeyMaterial::from(bytes.clone()));
(secret_key, client_id, bytes)
}
mod secret_key_provider {
use super::*;
#[tokio::test]
async fn returns_client_key_with_matching_id() {
let (secret_key, client_id, _) = random_secret_key();
let result = secret_key.client_key().await.unwrap();
assert_eq!(
result.key_id, client_id,
"client_key should preserve the client_id"
);
}
#[tokio::test]
async fn returns_client_key_with_correct_key_material() {
let (secret_key, _, bytes) = random_secret_key();
let result = secret_key.client_key().await.unwrap();
let expected_hex = base16ct::lower::encode_string(&bytes);
let actual_hex = result.to_hex_v1().unwrap();
assert_eq!(
actual_hex, expected_hex,
"client_key should preserve the key material"
);
}
#[tokio::test]
async fn returns_invalid_key_for_bad_material() {
let secret_key =
SecretKey::new(Uuid::new_v4(), ViturKeyMaterial::from(vec![0xDE, 0xAD]));
let err = secret_key.client_key().await.unwrap_err();
assert!(
matches!(err, KeyProviderError::InvalidKey(_)),
"expected InvalidKey for garbage bytes, got: {err:?}"
);
}
}
mod from_hex {
use super::*;
#[tokio::test]
async fn round_trips_through_key_provider() {
let (original, client_id, bytes) = random_secret_key();
let hex = base16ct::lower::encode_string(&bytes);
let from_hex = SecretKey::from_hex(client_id.to_string(), hex).unwrap();
let original_key = original.client_key().await.unwrap();
let from_hex_key = from_hex.client_key().await.unwrap();
assert_eq!(
original_key.key_id, from_hex_key.key_id,
"from_hex should produce the same client_id"
);
assert_eq!(
original_key.to_hex_v1().unwrap(),
from_hex_key.to_hex_v1().unwrap(),
"from_hex should produce the same key material"
);
}
#[test]
fn returns_invalid_key_for_bad_uuid() {
let err = SecretKey::from_hex("not-a-uuid".into(), "deadbeef".into()).unwrap_err();
assert!(
matches!(err, KeyProviderError::InvalidKey(_)),
"expected InvalidKey for bad UUID, got: {err:?}"
);
}
#[test]
fn returns_invalid_key_for_bad_hex() {
let uuid = Uuid::new_v4();
let err = SecretKey::from_hex(uuid.to_string(), "not-valid-hex!!".into()).unwrap_err();
assert!(
matches!(err, KeyProviderError::InvalidKey(_)),
"expected InvalidKey for bad hex, got: {err:?}"
);
}
}
mod from_env {
use super::*;
use crate::config::vars::{CS_CLIENT_ID, CS_CLIENT_KEY};
#[test]
fn returns_some_when_both_vars_set_with_valid_values() {
let (_, client_id, bytes) = random_secret_key();
let hex = base16ct::lower::encode_string(&bytes);
unsafe {
std::env::set_var(CS_CLIENT_ID, client_id.to_string());
std::env::set_var(CS_CLIENT_KEY, &hex);
}
let result = SecretKey::from_env().unwrap();
assert!(result.is_some(), "expected Some when both vars are set");
assert_eq!(result.unwrap().client_id, client_id);
unsafe {
std::env::remove_var(CS_CLIENT_ID);
std::env::remove_var(CS_CLIENT_KEY);
}
}
#[test]
fn returns_none_when_neither_var_set() {
unsafe {
std::env::remove_var(CS_CLIENT_ID);
std::env::remove_var(CS_CLIENT_KEY);
}
let result = SecretKey::from_env().unwrap();
assert!(result.is_none(), "expected None when neither var is set");
}
#[test]
fn returns_none_when_only_client_id_set() {
unsafe {
std::env::set_var(CS_CLIENT_ID, Uuid::new_v4().to_string());
std::env::remove_var(CS_CLIENT_KEY);
}
let result = SecretKey::from_env().unwrap();
assert!(
result.is_none(),
"expected None when only CS_CLIENT_ID is set"
);
unsafe {
std::env::remove_var(CS_CLIENT_ID);
}
}
#[test]
fn returns_none_when_only_client_key_set() {
unsafe {
std::env::remove_var(CS_CLIENT_ID);
std::env::set_var(CS_CLIENT_KEY, "deadbeef");
}
let result = SecretKey::from_env().unwrap();
assert!(
result.is_none(),
"expected None when only CS_CLIENT_KEY is set"
);
unsafe {
std::env::remove_var(CS_CLIENT_KEY);
}
}
#[test]
fn returns_err_when_both_set_but_invalid_uuid() {
unsafe {
std::env::set_var(CS_CLIENT_ID, "not-a-uuid");
std::env::set_var(CS_CLIENT_KEY, "deadbeef");
}
let err = SecretKey::from_env().unwrap_err();
assert!(
matches!(err, KeyProviderError::InvalidKey(_)),
"expected InvalidKey for bad UUID, got: {err:?}"
);
unsafe {
std::env::remove_var(CS_CLIENT_ID);
std::env::remove_var(CS_CLIENT_KEY);
}
}
}
mod profile_store_provider {
use super::*;
const TEST_WORKSPACE_ID: &str = "ZVATKW3VHMFG27DY";
#[tokio::test]
async fn loads_secret_key_from_disk() {
let dir = TempDir::new().unwrap();
let store = ProfileStore::new(dir.path());
store.init_workspace(TEST_WORKSPACE_ID).unwrap();
let (secret_key, client_id, bytes) = random_secret_key();
let ws_store = store.current_workspace_store().unwrap();
ws_store.save_profile(&secret_key).unwrap();
let result = store.client_key().await.unwrap();
assert_eq!(
result.key_id, client_id,
"should load and convert the stored SecretKey"
);
let expected_hex = base16ct::lower::encode_string(&bytes);
let actual_hex = result.to_hex_v1().unwrap();
assert_eq!(
actual_hex, expected_hex,
"should preserve the key material after round-tripping through disk"
);
}
#[tokio::test]
async fn returns_not_configured_when_no_workspace_set() {
let dir = TempDir::new().unwrap();
let store = ProfileStore::new(dir.path());
let err = store.client_key().await.unwrap_err();
assert!(
matches!(err, KeyProviderError::NotConfigured(_)),
"expected NotConfigured when no workspace is set, got: {err:?}"
);
}
#[tokio::test]
async fn returns_not_configured_when_file_missing() {
let dir = TempDir::new().unwrap();
let store = ProfileStore::new(dir.path());
store.init_workspace(TEST_WORKSPACE_ID).unwrap();
let err = store.client_key().await.unwrap_err();
assert!(
matches!(err, KeyProviderError::NotConfigured(_)),
"expected NotConfigured for missing file, got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("Profile not found"),
"error should explain the profile is missing, got: {msg}"
);
}
#[tokio::test]
async fn returns_load_error_for_invalid_json() {
let dir = TempDir::new().unwrap();
let store = ProfileStore::new(dir.path());
store.init_workspace(TEST_WORKSPACE_ID).unwrap();
let ws_dir = dir.path().join("workspaces").join(TEST_WORKSPACE_ID);
std::fs::create_dir_all(&ws_dir).unwrap();
std::fs::write(ws_dir.join(SecretKey::FILENAME), "not json").unwrap();
let err = store.client_key().await.unwrap_err();
assert!(
matches!(err, KeyProviderError::LoadError(_)),
"expected LoadError for corrupt file, got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("JSON error"),
"error should mention the JSON parse failure, got: {msg}"
);
}
}
}