Fix Secret Key decode rejecting valid keys; verify build
The first base32 symbol carries 3 data bits (26 symbols = 130 bits, 2 spare), but decode only accepted values 0-3, so ~half of generated keys failed to parse. Adds an edge-case test, rustfmt config, Cargo.lock, and on-demand cross targets instead of installing all seven in rust-toolchain.toml. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -75,10 +75,7 @@ pub fn unpad(padded: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
|
||||
return Err(CryptoError::Malformed);
|
||||
}
|
||||
let len = u32::from_le_bytes(padded[..4].try_into().unwrap()) as usize;
|
||||
padded
|
||||
.get(4..4 + len)
|
||||
.map(|d| Zeroizing::new(d.to_vec()))
|
||||
.ok_or(CryptoError::Malformed)
|
||||
padded.get(4..4 + len).map(|d| Zeroizing::new(d.to_vec())).ok_or(CryptoError::Malformed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+15
-16
@@ -20,24 +20,28 @@ pub struct DerivedKeys {
|
||||
|
||||
/// Default parameters for new accounts: RFC 9106 second recommended option.
|
||||
pub fn default_params() -> KdfParams {
|
||||
KdfParams {
|
||||
alg: "argon2id".into(),
|
||||
m_kib: 64 * 1024,
|
||||
t: 3,
|
||||
p: 4,
|
||||
salt: random_bytes::<16>().to_vec(),
|
||||
}
|
||||
KdfParams { alg: "argon2id".into(), m_kib: 64 * 1024, t: 3, p: 4, salt: random_bytes::<16>().to_vec() }
|
||||
}
|
||||
|
||||
/// Rejects parameters a malicious server might use to downgrade the KDF.
|
||||
pub fn validate(params: &KdfParams) -> Result<()> {
|
||||
if params.alg != "argon2id" || params.m_kib < MIN_KDF_M_KIB || params.t < MIN_KDF_T || params.p == 0 || params.salt.len() < 16 {
|
||||
if params.alg != "argon2id"
|
||||
|| params.m_kib < MIN_KDF_M_KIB
|
||||
|| params.t < MIN_KDF_T
|
||||
|| params.p == 0
|
||||
|| params.salt.len() < 16
|
||||
{
|
||||
return Err(CryptoError::WeakKdfParams);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn derive_keys(password: &str, secret_key: &SecretKey, account_id: Uuid, params: &KdfParams) -> Result<DerivedKeys> {
|
||||
pub fn derive_keys(
|
||||
password: &str,
|
||||
secret_key: &SecretKey,
|
||||
account_id: Uuid,
|
||||
params: &KdfParams,
|
||||
) -> Result<DerivedKeys> {
|
||||
validate(params)?;
|
||||
derive_keys_unchecked(password, secret_key, account_id, params)
|
||||
}
|
||||
@@ -54,9 +58,7 @@ pub(crate) fn derive_keys_unchecked(
|
||||
let mut argon_salt = [0u8; 16];
|
||||
let mut info = b"kestrove/v1/argon-salt".to_vec();
|
||||
info.extend_from_slice(account_id.as_bytes());
|
||||
Hkdf::<Sha256>::new(None, ¶ms.salt)
|
||||
.expand(&info, &mut argon_salt)
|
||||
.map_err(|_| CryptoError::Kdf)?;
|
||||
Hkdf::<Sha256>::new(None, ¶ms.salt).expand(&info, &mut argon_salt).map_err(|_| CryptoError::Kdf)?;
|
||||
|
||||
let argon = Argon2::new(
|
||||
Algorithm::Argon2id,
|
||||
@@ -132,10 +134,7 @@ mod tests {
|
||||
#[test]
|
||||
fn rejects_weak_params() {
|
||||
let sk = SecretKey::from_bytes([1u8; 16]);
|
||||
assert_eq!(
|
||||
derive_keys("pw", &sk, Uuid::nil(), &test_params()).err(),
|
||||
Some(CryptoError::WeakKdfParams)
|
||||
);
|
||||
assert_eq!(derive_keys("pw", &sk, Uuid::nil(), &test_params()).err(), Some(CryptoError::WeakKdfParams));
|
||||
assert!(validate(&default_params()).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,12 @@ impl AccountKeyset {
|
||||
let mut pt = Zeroizing::new(Vec::with_capacity(48));
|
||||
pt.extend_from_slice(vk.id.as_bytes());
|
||||
pt.extend_from_slice(vk.expose());
|
||||
seal_to(self.enc.id, &self.enc.public_bytes(), &pt, &Aad::build(ObjectType::VaultKey, self.account_id, Some(vault_id), None))
|
||||
seal_to(
|
||||
self.enc.id,
|
||||
&self.enc.public_bytes(),
|
||||
&pt,
|
||||
&Aad::build(ObjectType::VaultKey, self.account_id, Some(vault_id), None),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn open_vault_key(&self, vault_id: Uuid, env: &Envelope) -> Result<SymKey> {
|
||||
@@ -100,26 +105,40 @@ pub struct SealedItem {
|
||||
}
|
||||
|
||||
/// Encrypts an item's plaintext (already serialized) with a fresh per-item key.
|
||||
pub fn seal_item(account_id: Uuid, vault_id: Uuid, item_id: Uuid, vault_key: &SymKey, plaintext: &[u8]) -> Result<SealedItem> {
|
||||
pub fn seal_item(
|
||||
account_id: Uuid,
|
||||
vault_id: Uuid,
|
||||
item_id: Uuid,
|
||||
vault_key: &SymKey,
|
||||
plaintext: &[u8],
|
||||
) -> Result<SealedItem> {
|
||||
let item_key = SymKey::generate();
|
||||
let mut key_pt = Zeroizing::new(Vec::with_capacity(48));
|
||||
key_pt.extend_from_slice(item_key.id.as_bytes());
|
||||
key_pt.extend_from_slice(item_key.expose());
|
||||
let wrapped_item_key =
|
||||
vault_key.seal(&key_pt, &Aad::build(ObjectType::ItemKey, account_id, Some(vault_id), Some(item_id)))?;
|
||||
let payload = item_key.seal(&pad(plaintext), &Aad::build(ObjectType::Item, account_id, Some(vault_id), Some(item_id)))?;
|
||||
let payload =
|
||||
item_key.seal(&pad(plaintext), &Aad::build(ObjectType::Item, account_id, Some(vault_id), Some(item_id)))?;
|
||||
Ok(SealedItem { wrapped_item_key, payload })
|
||||
}
|
||||
|
||||
pub fn open_item(account_id: Uuid, vault_id: Uuid, item_id: Uuid, vault_key: &SymKey, sealed: &SealedItem) -> Result<Zeroizing<Vec<u8>>> {
|
||||
let key_pt =
|
||||
vault_key.open(&sealed.wrapped_item_key, &Aad::build(ObjectType::ItemKey, account_id, Some(vault_id), Some(item_id)))?;
|
||||
pub fn open_item(
|
||||
account_id: Uuid,
|
||||
vault_id: Uuid,
|
||||
item_id: Uuid,
|
||||
vault_key: &SymKey,
|
||||
sealed: &SealedItem,
|
||||
) -> Result<Zeroizing<Vec<u8>>> {
|
||||
let key_pt = vault_key
|
||||
.open(&sealed.wrapped_item_key, &Aad::build(ObjectType::ItemKey, account_id, Some(vault_id), Some(item_id)))?;
|
||||
if key_pt.len() != 48 {
|
||||
return Err(CryptoError::Malformed);
|
||||
}
|
||||
let id = Uuid::from_slice(&key_pt[..16]).map_err(|_| CryptoError::Malformed)?;
|
||||
let item_key = SymKey::from_bytes(id, key_pt[16..].try_into().map_err(|_| CryptoError::Malformed)?);
|
||||
let padded = item_key.open(&sealed.payload, &Aad::build(ObjectType::Item, account_id, Some(vault_id), Some(item_id)))?;
|
||||
let padded =
|
||||
item_key.open(&sealed.payload, &Aad::build(ObjectType::Item, account_id, Some(vault_id), Some(item_id)))?;
|
||||
unpad(&padded)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! HPKE (RFC 9180) sealing of small secrets (vault keys) to an account's X25519 public key.
|
||||
|
||||
use hpke::{
|
||||
aead::ChaCha20Poly1305, kdf::HkdfSha256, kem::X25519HkdfSha256, Deserializable, Kem as KemTrait, OpModeR,
|
||||
OpModeS, Serializable,
|
||||
aead::ChaCha20Poly1305, kdf::HkdfSha256, kem::X25519HkdfSha256, Deserializable, Kem as KemTrait, OpModeR, OpModeS,
|
||||
Serializable,
|
||||
};
|
||||
use kv_proto::{Alg, Envelope, FORMAT_VERSION};
|
||||
use rand_core::OsRng;
|
||||
@@ -45,18 +45,31 @@ impl EncKeypair {
|
||||
return Err(CryptoError::Unsupported);
|
||||
}
|
||||
let encapped = <Kem as KemTrait>::EncappedKey::from_bytes(&env.enc).map_err(|_| CryptoError::Malformed)?;
|
||||
hpke::single_shot_open::<ChaCha20Poly1305, HkdfSha256, Kem>(&OpModeR::Base, &self.sk, &encapped, INFO, &env.ct, aad)
|
||||
.map(Zeroizing::new)
|
||||
.map_err(|_| CryptoError::Decrypt)
|
||||
hpke::single_shot_open::<ChaCha20Poly1305, HkdfSha256, Kem>(
|
||||
&OpModeR::Base,
|
||||
&self.sk,
|
||||
&encapped,
|
||||
INFO,
|
||||
&env.ct,
|
||||
aad,
|
||||
)
|
||||
.map(Zeroizing::new)
|
||||
.map_err(|_| CryptoError::Decrypt)
|
||||
}
|
||||
}
|
||||
|
||||
/// Seals `plaintext` to a recipient public key (raw 32-byte X25519).
|
||||
pub fn seal_to(recipient_id: Uuid, recipient_pk: &[u8], plaintext: &[u8], aad: &[u8]) -> Result<Envelope> {
|
||||
let pk = <Kem as KemTrait>::PublicKey::from_bytes(recipient_pk).map_err(|_| CryptoError::Malformed)?;
|
||||
let (encapped, ct) =
|
||||
hpke::single_shot_seal::<ChaCha20Poly1305, HkdfSha256, Kem, _>(&OpModeS::Base, &pk, INFO, plaintext, aad, &mut OsRng)
|
||||
.map_err(|_| CryptoError::Encrypt)?;
|
||||
let (encapped, ct) = hpke::single_shot_seal::<ChaCha20Poly1305, HkdfSha256, Kem, _>(
|
||||
&OpModeS::Base,
|
||||
&pk,
|
||||
INFO,
|
||||
plaintext,
|
||||
aad,
|
||||
&mut OsRng,
|
||||
)
|
||||
.map_err(|_| CryptoError::Encrypt)?;
|
||||
Ok(Envelope {
|
||||
v: FORMAT_VERSION,
|
||||
alg: Alg::HpkeX25519ChaCha,
|
||||
|
||||
@@ -44,10 +44,7 @@ impl SecretKey {
|
||||
/// Accepts any case, spaces or hyphens, and the usual Crockford confusables (O→0, I/L→1).
|
||||
pub fn parse(s: &str) -> Result<Self> {
|
||||
let cleaned: Zeroizing<String> = Zeroizing::new(
|
||||
s.chars()
|
||||
.filter(|c| !c.is_whitespace() && *c != '-')
|
||||
.map(|c| c.to_ascii_uppercase())
|
||||
.collect(),
|
||||
s.chars().filter(|c| !c.is_whitespace() && *c != '-').map(|c| c.to_ascii_uppercase()).collect(),
|
||||
);
|
||||
let rest = cleaned.strip_prefix(PREFIX).ok_or(CryptoError::BadSecretKey)?;
|
||||
if rest.len() != 27 {
|
||||
@@ -86,7 +83,8 @@ fn decode(s: &str) -> Result<[u8; 16]> {
|
||||
let mut n: u128 = 0;
|
||||
for (i, c) in s.bytes().enumerate() {
|
||||
let v = symbol_value(c)? as u128;
|
||||
if i == 0 && v > 0b11 {
|
||||
// 26 symbols carry 130 bits; the 2 spare bits sit at the top, so the first symbol holds 3 data bits.
|
||||
if i == 0 && v > 0b111 {
|
||||
return Err(CryptoError::BadSecretKey); // would overflow 128 bits
|
||||
}
|
||||
n = (n << 5) | v;
|
||||
@@ -109,6 +107,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extremes_roundtrip() {
|
||||
for b in [[0u8; 16], [0xff; 16], [0x80; 16]] {
|
||||
let k = SecretKey::from_bytes(b);
|
||||
assert_eq!(SecretKey::parse(&k.to_display()).unwrap().expose(), &b);
|
||||
}
|
||||
// First symbol 8 would need bit 128: reject.
|
||||
let max = SecretKey::from_bytes([0xff; 16]).to_display().to_string();
|
||||
assert!(max.starts_with("K1-7"));
|
||||
assert!(SecretKey::parse(&max.replacen("K1-7", "K1-8", 1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_typo() {
|
||||
let k = SecretKey::from_bytes([0xAB; 16]);
|
||||
|
||||
Reference in New Issue
Block a user