use serde::{Deserialize, Serialize};
use crate::SealedError;
pub const PAYLOAD_ALG: &str = "A256GCM";
pub const PRF_WRAP_ALG: &str = "HKDF-SHA256+A256KW";
pub const RECOVERY_WRAP_ALG: &str = "ARGON2ID+A256KW";
pub const WRAPPED_KEY_LEN: usize = 40;
pub const PRF_SALT_LEN: usize = 32;
pub const RECOVERY_SALT_LEN: usize = 16;
pub const CIPHERTEXT_FLOOR: usize = 28;
pub const RECOVERY_FLOOR: RecoveryParams = RecoveryParams {
m_kib: 65536,
t: 3,
p: 1,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecoveryParams {
pub m_kib: u32,
pub t: u32,
pub p: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Wrap {
pub id: String,
pub kind: String,
pub alg: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params: Option<RecoveryParams>,
pub salt: String,
pub wrapped_key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreateBlob {
pub blob_id: String,
pub version: u32,
pub purpose: String,
pub alg: String,
pub ciphertext: String,
pub wraps: Vec<Wrap>,
pub created_by_credential: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct WrapEdit {
pub version: u32,
pub wraps: Vec<Wrap>,
}
pub fn b64url_decode(value: &str) -> Option<Vec<u8>> {
use base64::Engine as _;
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(value)
.ok()?;
let mut canonical = String::with_capacity(value.len());
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode_string(&decoded, &mut canonical);
(canonical == value).then_some(decoded)
}
#[must_use]
pub fn b64url_encode(bytes: &[u8]) -> String {
use base64::Engine as _;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
#[must_use]
pub fn is_blob_id(blob_id: &str) -> bool {
(1..=64).contains(&blob_id.len())
&& blob_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
#[must_use]
pub fn is_purpose(purpose: &str) -> bool {
(1..=64).contains(&purpose.len())
&& purpose.bytes().all(|b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b'_' || b == b'-'
})
}
pub fn validate_wrap(wrap: &Wrap) -> Result<(), SealedError> {
let fail = |what: &str| SealedError::BadWrap(format!("wrap `{}`: {what}", wrap.id));
let wrapped = b64url_decode(&wrap.wrapped_key)
.ok_or_else(|| fail("wrapped_key is not canonical base64url"))?;
if wrapped.len() != WRAPPED_KEY_LEN {
return Err(fail(&format!(
"wrapped_key must be {WRAPPED_KEY_LEN} bytes, got {}",
wrapped.len()
)));
}
match wrap.kind.as_str() {
"prf" => {
if wrap.alg != PRF_WRAP_ALG {
return Err(fail(&format!(
"alg must be {PRF_WRAP_ALG}, got {}",
wrap.alg
)));
}
if wrap.params.is_some() {
return Err(fail("prf wraps carry no params"));
}
let salt =
b64url_decode(&wrap.salt).ok_or_else(|| fail("salt is not canonical base64url"))?;
if salt.len() != PRF_SALT_LEN {
return Err(fail(&format!(
"salt must be {PRF_SALT_LEN} bytes, got {}",
salt.len()
)));
}
Ok(())
}
"recovery" => {
if wrap.alg != RECOVERY_WRAP_ALG {
return Err(fail(&format!(
"alg must be {RECOVERY_WRAP_ALG}, got {}",
wrap.alg
)));
}
let salt =
b64url_decode(&wrap.salt).ok_or_else(|| fail("salt is not canonical base64url"))?;
if salt.len() != RECOVERY_SALT_LEN {
return Err(fail(&format!(
"salt must be {RECOVERY_SALT_LEN} bytes, got {}",
salt.len()
)));
}
let params = wrap
.params
.ok_or_else(|| fail("recovery wrap is missing its params"))?;
if params.m_kib < RECOVERY_FLOOR.m_kib
|| params.t < RECOVERY_FLOOR.t
|| params.p < RECOVERY_FLOOR.p
{
return Err(SealedError::WeakParams(format!(
"wrap `{}`: params m_kib={} t={} p={} are below the accepted floor \
m_kib>={} t>={} p>={} — refusing (downgrade protection)",
wrap.id,
params.m_kib,
params.t,
params.p,
RECOVERY_FLOOR.m_kib,
RECOVERY_FLOOR.t,
RECOVERY_FLOOR.p
)));
}
Ok(())
}
other => Err(fail(&format!("unknown kind `{other}`"))),
}
}
pub fn validate_wraps(wraps: &[Wrap]) -> Result<(), SealedError> {
if wraps.len() < 2 {
return Err(SealedError::BadInput(format!(
"a record carries at least 2 wraps, got {}",
wraps.len()
)));
}
let mut seen = std::collections::HashSet::new();
for wrap in wraps {
if !seen.insert(wrap.id.as_str()) {
return Err(SealedError::BadInput(format!(
"duplicate wrap id `{}`",
wrap.id
)));
}
validate_wrap(wrap)?;
}
Ok(())
}
pub fn validate_create(blob: &CreateBlob) -> Result<(), SealedError> {
if !is_blob_id(&blob.blob_id) {
return Err(SealedError::BadInput(
"blob_id must be 1-64 chars of [A-Za-z0-9_-]".into(),
));
}
if !is_purpose(&blob.purpose) {
return Err(SealedError::BadInput(
"purpose must be 1-64 chars of [a-z0-9._-]".into(),
));
}
if blob.version < 1 {
return Err(SealedError::BadInput("version must be a u32 >= 1".into()));
}
if blob.alg != PAYLOAD_ALG {
return Err(SealedError::BadInput(format!(
"alg must be {PAYLOAD_ALG}, got {}",
blob.alg
)));
}
if blob.created_by_credential.is_empty() {
return Err(SealedError::BadInput(
"created_by_credential must be non-empty".into(),
));
}
validate_wraps(&blob.wraps)?;
let ciphertext = b64url_decode(&blob.ciphertext)
.ok_or_else(|| SealedError::BadInput("ciphertext is not canonical base64url".into()))?;
if ciphertext.len() < CIPHERTEXT_FLOOR {
return Err(SealedError::BadInput(format!(
"ciphertext must be at least {CIPHERTEXT_FLOOR} bytes (nonce + tag), got {}",
ciphertext.len()
)));
}
Ok(())
}