runtime: normalize structured secret errors
This commit is contained in:
@@ -2,18 +2,18 @@
|
|||||||
|
|
||||||
## Current
|
## Current
|
||||||
|
|
||||||
### `feat/typed-timestamps`
|
### `feat/error-structure-normalization`
|
||||||
|
|
||||||
Status: in_progress
|
Status: in_progress
|
||||||
|
|
||||||
DoD:
|
DoD:
|
||||||
- domain and registry models use typed timestamps instead of `String`
|
- runtime and API errors expose structured context where stable fields exist
|
||||||
- SQL row decoding uses timestamp types directly where possible
|
- secret/runtime failure classes are mappable without parsing ad hoc message text
|
||||||
- API serialization stays RFC 3339 compatible
|
- opaque third-party details remain only where no stable structure exists
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- `feat/error-structure-normalization`
|
- `feat/correlation-id-propagation`
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
|
|||||||
@@ -2169,6 +2169,7 @@ impl AdminService {
|
|||||||
.get_auth_profile(workspace_id, auth_profile_id)
|
.get_auth_profile(workspace_id, auth_profile_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| RuntimeError::SecretCrypto {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "load auth profile",
|
||||||
details: error.to_string(),
|
details: error.to_string(),
|
||||||
})?
|
})?
|
||||||
.ok_or_else(|| RuntimeError::MissingAuthProfile {
|
.ok_or_else(|| RuntimeError::MissingAuthProfile {
|
||||||
@@ -2207,6 +2208,7 @@ impl AdminService {
|
|||||||
streaming.max_session_lifetime_ms.unwrap_or(60_000) as i64,
|
streaming.max_session_lifetime_ms.unwrap_or(60_000) as i64,
|
||||||
))
|
))
|
||||||
.ok_or_else(|| RuntimeError::SecretCrypto {
|
.ok_or_else(|| RuntimeError::SecretCrypto {
|
||||||
|
operation: "compute stream session expiration",
|
||||||
details: "failed to compute stream session expiration".to_owned(),
|
details: "failed to compute stream session expiration".to_owned(),
|
||||||
})?;
|
})?;
|
||||||
let session = StreamSession {
|
let session = StreamSession {
|
||||||
@@ -2234,6 +2236,7 @@ impl AdminService {
|
|||||||
.create_stream_session(CreateStreamSessionRequest { session: &session })
|
.create_stream_session(CreateStreamSessionRequest { session: &session })
|
||||||
.await
|
.await
|
||||||
.map_err(|error| RuntimeError::SecretCrypto {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "persist stream session",
|
||||||
details: error.to_string(),
|
details: error.to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -2267,6 +2270,7 @@ impl AdminService {
|
|||||||
streaming.max_session_lifetime_ms.unwrap_or(300_000) as i64,
|
streaming.max_session_lifetime_ms.unwrap_or(300_000) as i64,
|
||||||
))
|
))
|
||||||
.ok_or_else(|| RuntimeError::SecretCrypto {
|
.ok_or_else(|| RuntimeError::SecretCrypto {
|
||||||
|
operation: "compute async job expiration",
|
||||||
details: "failed to compute async job expiration".to_owned(),
|
details: "failed to compute async job expiration".to_owned(),
|
||||||
})?;
|
})?;
|
||||||
let job = AsyncJobHandle {
|
let job = AsyncJobHandle {
|
||||||
@@ -2287,6 +2291,7 @@ impl AdminService {
|
|||||||
.create_async_job(CreateAsyncJobRequest { job: &job })
|
.create_async_job(CreateAsyncJobRequest { job: &job })
|
||||||
.await
|
.await
|
||||||
.map_err(|error| RuntimeError::SecretCrypto {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "persist async job",
|
||||||
details: error.to_string(),
|
details: error.to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -2376,6 +2381,7 @@ impl AdminService {
|
|||||||
.get_secret(workspace_id, secret_id)
|
.get_secret(workspace_id, secret_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| RuntimeError::SecretCrypto {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "load secret",
|
||||||
details: error.to_string(),
|
details: error.to_string(),
|
||||||
})?
|
})?
|
||||||
.ok_or_else(|| RuntimeError::MissingSecret {
|
.ok_or_else(|| RuntimeError::MissingSecret {
|
||||||
@@ -2386,6 +2392,7 @@ impl AdminService {
|
|||||||
.get_current_secret_version(workspace_id, secret_id)
|
.get_current_secret_version(workspace_id, secret_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| RuntimeError::SecretCrypto {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "load current secret version",
|
||||||
details: error.to_string(),
|
details: error.to_string(),
|
||||||
})?
|
})?
|
||||||
.ok_or_else(|| RuntimeError::MissingSecretVersion {
|
.ok_or_else(|| RuntimeError::MissingSecretVersion {
|
||||||
@@ -2400,6 +2407,7 @@ impl AdminService {
|
|||||||
.touch_secret(workspace_id, secret_id, &used_at)
|
.touch_secret(workspace_id, secret_id, &used_at)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| RuntimeError::SecretCrypto {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "touch secret",
|
||||||
details: error.to_string(),
|
details: error.to_string(),
|
||||||
})?;
|
})?;
|
||||||
secrets.insert(secret_id.clone(), plaintext);
|
secrets.insert(secret_id.clone(), plaintext);
|
||||||
@@ -4648,6 +4656,7 @@ async fn resolve_runtime_auth_for_task(
|
|||||||
.get_auth_profile(workspace_id, auth_profile_id)
|
.get_auth_profile(workspace_id, auth_profile_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| RuntimeError::SecretCrypto {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "load auth profile",
|
||||||
details: error.to_string(),
|
details: error.to_string(),
|
||||||
})?
|
})?
|
||||||
.ok_or_else(|| RuntimeError::MissingAuthProfile {
|
.ok_or_else(|| RuntimeError::MissingAuthProfile {
|
||||||
@@ -4662,6 +4671,7 @@ async fn resolve_runtime_auth_for_task(
|
|||||||
.get_secret(workspace_id, secret_id)
|
.get_secret(workspace_id, secret_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| RuntimeError::SecretCrypto {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "load secret",
|
||||||
details: error.to_string(),
|
details: error.to_string(),
|
||||||
})?
|
})?
|
||||||
.ok_or_else(|| RuntimeError::MissingSecret {
|
.ok_or_else(|| RuntimeError::MissingSecret {
|
||||||
@@ -4671,6 +4681,7 @@ async fn resolve_runtime_auth_for_task(
|
|||||||
.get_current_secret_version(workspace_id, secret_id)
|
.get_current_secret_version(workspace_id, secret_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| RuntimeError::SecretCrypto {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "load current secret version",
|
||||||
details: error.to_string(),
|
details: error.to_string(),
|
||||||
})?
|
})?
|
||||||
.ok_or_else(|| RuntimeError::MissingSecretVersion {
|
.ok_or_else(|| RuntimeError::MissingSecretVersion {
|
||||||
@@ -4685,6 +4696,7 @@ async fn resolve_runtime_auth_for_task(
|
|||||||
.touch_secret(workspace_id, secret_id, &used_at)
|
.touch_secret(workspace_id, secret_id, &used_at)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| RuntimeError::SecretCrypto {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "touch secret",
|
||||||
details: error.to_string(),
|
details: error.to_string(),
|
||||||
})?;
|
})?;
|
||||||
secrets.insert(secret_id.clone(), plaintext);
|
secrets.insert(secret_id.clone(), plaintext);
|
||||||
|
|||||||
@@ -542,6 +542,7 @@ async fn resolve_runtime_auth_for_task(
|
|||||||
.get_auth_profile(workspace_id, auth_profile_id)
|
.get_auth_profile(workspace_id, auth_profile_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| RuntimeError::SecretCrypto {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "load auth profile",
|
||||||
details: error.to_string(),
|
details: error.to_string(),
|
||||||
})?
|
})?
|
||||||
.ok_or_else(|| RuntimeError::MissingAuthProfile {
|
.ok_or_else(|| RuntimeError::MissingAuthProfile {
|
||||||
@@ -567,6 +568,7 @@ async fn resolve_auth_profile(
|
|||||||
.get_secret(workspace_id, secret_id)
|
.get_secret(workspace_id, secret_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| RuntimeError::SecretCrypto {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "load secret",
|
||||||
details: error.to_string(),
|
details: error.to_string(),
|
||||||
})?
|
})?
|
||||||
.ok_or_else(|| RuntimeError::MissingSecret {
|
.ok_or_else(|| RuntimeError::MissingSecret {
|
||||||
@@ -576,6 +578,7 @@ async fn resolve_auth_profile(
|
|||||||
.get_current_secret_version(workspace_id, secret_id)
|
.get_current_secret_version(workspace_id, secret_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| RuntimeError::SecretCrypto {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "load current secret version",
|
||||||
details: error.to_string(),
|
details: error.to_string(),
|
||||||
})?
|
})?
|
||||||
.ok_or_else(|| RuntimeError::MissingSecretVersion {
|
.ok_or_else(|| RuntimeError::MissingSecretVersion {
|
||||||
@@ -590,6 +593,7 @@ async fn resolve_auth_profile(
|
|||||||
.touch_secret(workspace_id, secret_id, &used_at)
|
.touch_secret(workspace_id, secret_id, &used_at)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| RuntimeError::SecretCrypto {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "touch secret",
|
||||||
details: error.to_string(),
|
details: error.to_string(),
|
||||||
})?;
|
})?;
|
||||||
secrets.insert(SecretId::new(secret_id.as_str()), plaintext);
|
secrets.insert(SecretId::new(secret_id.as_str()), plaintext);
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ fn extract_secret_string(
|
|||||||
let Some(object) = value.as_object() else {
|
let Some(object) = value.as_object() else {
|
||||||
return Err(RuntimeError::InvalidAuthSecretValue {
|
return Err(RuntimeError::InvalidAuthSecretValue {
|
||||||
secret_id: secret_id.as_str().to_owned(),
|
secret_id: secret_id.as_str().to_owned(),
|
||||||
details: "secret payload must be a string or object".to_owned(),
|
reason: "secret payload must be a string or object".to_owned(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ fn extract_secret_string(
|
|||||||
|
|
||||||
Err(RuntimeError::InvalidAuthSecretValue {
|
Err(RuntimeError::InvalidAuthSecretValue {
|
||||||
secret_id: secret_id.as_str().to_owned(),
|
secret_id: secret_id.as_str().to_owned(),
|
||||||
details: format!(
|
reason: format!(
|
||||||
"secret payload must contain one of: {}",
|
"secret payload must contain one of: {}",
|
||||||
preferred_fields.join(", ")
|
preferred_fields.join(", ")
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -33,18 +33,21 @@ pub enum RuntimeError {
|
|||||||
operation_id: String,
|
operation_id: String,
|
||||||
mode: ExecutionMode,
|
mode: ExecutionMode,
|
||||||
},
|
},
|
||||||
#[error("invalid prepared request: {details}")]
|
#[error("invalid prepared request at {field}: {reason}")]
|
||||||
InvalidPreparedRequest { details: String },
|
InvalidPreparedRequest { field: String, reason: String },
|
||||||
#[error("invalid streaming payload: {details}")]
|
#[error("invalid streaming payload at {field}: {reason}")]
|
||||||
InvalidStreamingPayload { details: String },
|
InvalidStreamingPayload { field: String, reason: String },
|
||||||
#[error("auth profile {auth_profile_id} was not found")]
|
#[error("auth profile {auth_profile_id} was not found")]
|
||||||
MissingAuthProfile { auth_profile_id: String },
|
MissingAuthProfile { auth_profile_id: String },
|
||||||
#[error("secret {secret_id} was not found")]
|
#[error("secret {secret_id} was not found")]
|
||||||
MissingSecret { secret_id: String },
|
MissingSecret { secret_id: String },
|
||||||
#[error("secret {secret_id} does not have current version {version}")]
|
#[error("secret {secret_id} does not have current version {version}")]
|
||||||
MissingSecretVersion { secret_id: String, version: u32 },
|
MissingSecretVersion { secret_id: String, version: u32 },
|
||||||
#[error("invalid secret payload for {secret_id}: {details}")]
|
#[error("invalid secret payload for {secret_id}: {reason}")]
|
||||||
InvalidAuthSecretValue { secret_id: String, details: String },
|
InvalidAuthSecretValue { secret_id: String, reason: String },
|
||||||
#[error("secret crypto error: {details}")]
|
#[error("secret crypto error during {operation}: {details}")]
|
||||||
SecretCrypto { details: String },
|
SecretCrypto {
|
||||||
|
operation: &'static str,
|
||||||
|
details: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,7 +395,8 @@ impl PreparedRequest {
|
|||||||
mapped
|
mapped
|
||||||
.get("request")
|
.get("request")
|
||||||
.ok_or_else(|| RuntimeError::InvalidPreparedRequest {
|
.ok_or_else(|| RuntimeError::InvalidPreparedRequest {
|
||||||
details: "mapped input must contain request root".to_owned(),
|
field: "request".to_owned(),
|
||||||
|
reason: "mapped input must contain request root".to_owned(),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -460,7 +461,8 @@ fn read_string_map(
|
|||||||
|
|
||||||
let Some(object) = value.as_object() else {
|
let Some(object) = value.as_object() else {
|
||||||
return Err(RuntimeError::InvalidPreparedRequest {
|
return Err(RuntimeError::InvalidPreparedRequest {
|
||||||
details: format!("{field_name} must be an object"),
|
field: field_name.to_owned(),
|
||||||
|
reason: "must be an object".to_owned(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -468,7 +470,10 @@ fn read_string_map(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|(key, value)| stringify_value(value).map(|value| (key.clone(), value)))
|
.map(|(key, value)| stringify_value(value).map(|value| (key.clone(), value)))
|
||||||
.collect::<Result<BTreeMap<_, _>, _>>()
|
.collect::<Result<BTreeMap<_, _>, _>>()
|
||||||
.map_err(|details| RuntimeError::InvalidPreparedRequest { details })
|
.map_err(|reason| RuntimeError::InvalidPreparedRequest {
|
||||||
|
field: field_name.to_owned(),
|
||||||
|
reason,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stringify_value(value: &Value) -> Result<String, String> {
|
fn stringify_value(value: &Value) -> Result<String, String> {
|
||||||
@@ -525,7 +530,7 @@ mod tests {
|
|||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio_tungstenite::accept_async;
|
use tokio_tungstenite::accept_async;
|
||||||
|
|
||||||
use crate::{RuntimeError, RuntimeExecutor, RuntimeOperation};
|
use crate::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation};
|
||||||
|
|
||||||
fn timestamp(value: &str) -> OffsetDateTime {
|
fn timestamp(value: &str) -> OffsetDateTime {
|
||||||
OffsetDateTime::parse(value, &Rfc3339).unwrap()
|
OffsetDateTime::parse(value, &Rfc3339).unwrap()
|
||||||
@@ -638,6 +643,19 @@ mod tests {
|
|||||||
assert!(matches!(error, RuntimeError::Schema(_)));
|
assert!(matches!(error, RuntimeError::Schema(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_missing_request_root_with_structured_error() {
|
||||||
|
let error = PreparedRequest::from_mapping_output(&json!({})).unwrap_err();
|
||||||
|
|
||||||
|
match error {
|
||||||
|
RuntimeError::InvalidPreparedRequest { field, reason } => {
|
||||||
|
assert_eq!(field, "request");
|
||||||
|
assert_eq!(reason, "mapped input must contain request root");
|
||||||
|
}
|
||||||
|
other => panic!("unexpected error: {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn propagates_external_rest_errors() {
|
async fn propagates_external_rest_errors() {
|
||||||
let base_url = spawn_runtime_server().await;
|
let base_url = spawn_runtime_server().await;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ impl SecretCrypto {
|
|||||||
let trimmed = master_key.trim();
|
let trimmed = master_key.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return Err(RuntimeError::SecretCrypto {
|
return Err(RuntimeError::SecretCrypto {
|
||||||
|
operation: "initialize secret crypto",
|
||||||
details: "CRANK_MASTER_KEY must not be empty".to_owned(),
|
details: "CRANK_MASTER_KEY must not be empty".to_owned(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -54,16 +55,19 @@ impl SecretCrypto {
|
|||||||
pub fn decrypt(&self, key_version: &str, ciphertext: &str) -> Result<Value, RuntimeError> {
|
pub fn decrypt(&self, key_version: &str, ciphertext: &str) -> Result<Value, RuntimeError> {
|
||||||
let envelope: CipherEnvelope =
|
let envelope: CipherEnvelope =
|
||||||
serde_json::from_str(ciphertext).map_err(|error| RuntimeError::SecretCrypto {
|
serde_json::from_str(ciphertext).map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "decode secret envelope",
|
||||||
details: format!("failed to decode secret envelope: {error}"),
|
details: format!("failed to decode secret envelope: {error}"),
|
||||||
})?;
|
})?;
|
||||||
let nonce_bytes =
|
let nonce_bytes =
|
||||||
STANDARD
|
STANDARD
|
||||||
.decode(envelope.nonce_b64)
|
.decode(envelope.nonce_b64)
|
||||||
.map_err(|error| RuntimeError::SecretCrypto {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "decode secret nonce",
|
||||||
details: format!("failed to decode secret nonce: {error}"),
|
details: format!("failed to decode secret nonce: {error}"),
|
||||||
})?;
|
})?;
|
||||||
let ciphertext_bytes = STANDARD.decode(envelope.ciphertext_b64).map_err(|error| {
|
let ciphertext_bytes = STANDARD.decode(envelope.ciphertext_b64).map_err(|error| {
|
||||||
RuntimeError::SecretCrypto {
|
RuntimeError::SecretCrypto {
|
||||||
|
operation: "decode secret payload",
|
||||||
details: format!("failed to decode secret payload: {error}"),
|
details: format!("failed to decode secret payload: {error}"),
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
@@ -71,10 +75,12 @@ impl SecretCrypto {
|
|||||||
let plaintext = cipher
|
let plaintext = cipher
|
||||||
.decrypt(Nonce::from_slice(&nonce_bytes), ciphertext_bytes.as_ref())
|
.decrypt(Nonce::from_slice(&nonce_bytes), ciphertext_bytes.as_ref())
|
||||||
.map_err(|error| RuntimeError::SecretCrypto {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "decrypt secret value",
|
||||||
details: format!("failed to decrypt secret value: {error}"),
|
details: format!("failed to decrypt secret value: {error}"),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
serde_json::from_slice(&plaintext).map_err(|error| RuntimeError::SecretCrypto {
|
serde_json::from_slice(&plaintext).map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "deserialize secret value",
|
||||||
details: format!("failed to deserialize secret value: {error}"),
|
details: format!("failed to deserialize secret value: {error}"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -84,6 +90,7 @@ impl SecretCrypto {
|
|||||||
LEGACY_KEY_VERSION => Ok(&self.legacy_cipher),
|
LEGACY_KEY_VERSION => Ok(&self.legacy_cipher),
|
||||||
CURRENT_KEY_VERSION => Ok(&self.current_cipher),
|
CURRENT_KEY_VERSION => Ok(&self.current_cipher),
|
||||||
other => Err(RuntimeError::SecretCrypto {
|
other => Err(RuntimeError::SecretCrypto {
|
||||||
|
operation: "select secret key version",
|
||||||
details: format!("unsupported secret key version: {other}"),
|
details: format!("unsupported secret key version: {other}"),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
@@ -93,6 +100,7 @@ impl SecretCrypto {
|
|||||||
fn derive_legacy_cipher(master_key: &str) -> Result<Aes256Gcm, RuntimeError> {
|
fn derive_legacy_cipher(master_key: &str) -> Result<Aes256Gcm, RuntimeError> {
|
||||||
let digest = Sha256::digest(master_key.as_bytes());
|
let digest = Sha256::digest(master_key.as_bytes());
|
||||||
Aes256Gcm::new_from_slice(digest.as_slice()).map_err(|error| RuntimeError::SecretCrypto {
|
Aes256Gcm::new_from_slice(digest.as_slice()).map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "initialize legacy secret crypto",
|
||||||
details: format!("failed to initialize legacy secret crypto: {error}"),
|
details: format!("failed to initialize legacy secret crypto: {error}"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -102,9 +110,11 @@ fn derive_hkdf_cipher(master_key: &str) -> Result<Aes256Gcm, RuntimeError> {
|
|||||||
let mut key_bytes = [0_u8; 32];
|
let mut key_bytes = [0_u8; 32];
|
||||||
hkdf.expand(SECRET_ENVELOPE_INFO, &mut key_bytes)
|
hkdf.expand(SECRET_ENVELOPE_INFO, &mut key_bytes)
|
||||||
.map_err(|error| RuntimeError::SecretCrypto {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "derive secret key with hkdf",
|
||||||
details: format!("failed to derive secret key with HKDF: {error}"),
|
details: format!("failed to derive secret key with HKDF: {error}"),
|
||||||
})?;
|
})?;
|
||||||
Aes256Gcm::new_from_slice(&key_bytes).map_err(|error| RuntimeError::SecretCrypto {
|
Aes256Gcm::new_from_slice(&key_bytes).map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "initialize secret crypto",
|
||||||
details: format!("failed to initialize secret crypto: {error}"),
|
details: format!("failed to initialize secret crypto: {error}"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -115,6 +125,7 @@ fn encrypt_value_with_cipher(
|
|||||||
action: &str,
|
action: &str,
|
||||||
) -> Result<String, RuntimeError> {
|
) -> Result<String, RuntimeError> {
|
||||||
let plaintext = serde_json::to_vec(value).map_err(|error| RuntimeError::SecretCrypto {
|
let plaintext = serde_json::to_vec(value).map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "serialize secret value",
|
||||||
details: format!("failed to serialize secret value: {error}"),
|
details: format!("failed to serialize secret value: {error}"),
|
||||||
})?;
|
})?;
|
||||||
let mut nonce_bytes = [0_u8; 12];
|
let mut nonce_bytes = [0_u8; 12];
|
||||||
@@ -124,6 +135,7 @@ fn encrypt_value_with_cipher(
|
|||||||
cipher
|
cipher
|
||||||
.encrypt(nonce, plaintext.as_ref())
|
.encrypt(nonce, plaintext.as_ref())
|
||||||
.map_err(|error| RuntimeError::SecretCrypto {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "encrypt secret value",
|
||||||
details: format!("failed to {action}: {error}"),
|
details: format!("failed to {action}: {error}"),
|
||||||
})?;
|
})?;
|
||||||
let envelope = CipherEnvelope {
|
let envelope = CipherEnvelope {
|
||||||
@@ -132,6 +144,7 @@ fn encrypt_value_with_cipher(
|
|||||||
};
|
};
|
||||||
|
|
||||||
serde_json::to_string(&envelope).map_err(|error| RuntimeError::SecretCrypto {
|
serde_json::to_string(&envelope).map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "encode secret ciphertext",
|
||||||
details: format!("failed to encode secret ciphertext: {error}"),
|
details: format!("failed to encode secret ciphertext: {error}"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -148,6 +161,7 @@ fn current_key_bytes(master_key: &str) -> Result<[u8; 32], RuntimeError> {
|
|||||||
let mut key_bytes = [0_u8; 32];
|
let mut key_bytes = [0_u8; 32];
|
||||||
hkdf.expand(SECRET_ENVELOPE_INFO, &mut key_bytes)
|
hkdf.expand(SECRET_ENVELOPE_INFO, &mut key_bytes)
|
||||||
.map_err(|error| RuntimeError::SecretCrypto {
|
.map_err(|error| RuntimeError::SecretCrypto {
|
||||||
|
operation: "derive secret key with hkdf",
|
||||||
details: format!("failed to derive secret key with HKDF: {error}"),
|
details: format!("failed to derive secret key with HKDF: {error}"),
|
||||||
})?;
|
})?;
|
||||||
Ok(key_bytes)
|
Ok(key_bytes)
|
||||||
@@ -163,6 +177,7 @@ fn legacy_key_bytes(master_key: &str) -> [u8; 32] {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use crate::RuntimeError;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@@ -202,11 +217,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn rejects_empty_master_key() {
|
fn rejects_empty_master_key() {
|
||||||
let error = SecretCrypto::new(" ").err().unwrap();
|
let error = SecretCrypto::new(" ").err().unwrap();
|
||||||
assert!(
|
match error {
|
||||||
error
|
RuntimeError::SecretCrypto { operation, details } => {
|
||||||
.to_string()
|
assert_eq!(operation, "initialize secret crypto");
|
||||||
.contains("CRANK_MASTER_KEY must not be empty")
|
assert_eq!(details, "CRANK_MASTER_KEY must not be empty");
|
||||||
);
|
}
|
||||||
|
other => panic!("unexpected error: {other}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -246,6 +263,12 @@ mod tests {
|
|||||||
let ciphertext = crypto.encrypt(&json!({"token": "top-secret"})).unwrap();
|
let ciphertext = crypto.encrypt(&json!({"token": "top-secret"})).unwrap();
|
||||||
let error = crypto.decrypt("v999", &ciphertext).unwrap_err();
|
let error = crypto.decrypt("v999", &ciphertext).unwrap_err();
|
||||||
|
|
||||||
assert!(error.to_string().contains("unsupported secret key version"));
|
match error {
|
||||||
|
RuntimeError::SecretCrypto { operation, details } => {
|
||||||
|
assert_eq!(operation, "select secret key version");
|
||||||
|
assert!(details.contains("unsupported secret key version"));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected error: {other}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user