use crate::CryptoError;
use crate::error::FormatDefect;
use crate::recipient::entry::RecipientEntry;
use crate::recipient::native::{argon2id, x25519};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NativeRecipientType {
Argon2id,
X25519,
}
impl NativeRecipientType {
pub(crate) fn from_type_name(name: &str) -> Option<Self> {
match name {
argon2id::TYPE_NAME => Some(Self::Argon2id),
x25519::TYPE_NAME => Some(Self::X25519),
_ => None,
}
}
pub(crate) const fn type_name(self) -> &'static str {
match self {
Self::Argon2id => argon2id::TYPE_NAME,
Self::X25519 => x25519::TYPE_NAME,
}
}
pub(crate) const fn body_len(self) -> usize {
match self {
Self::Argon2id => argon2id::BODY_LENGTH,
Self::X25519 => x25519::BODY_LENGTH,
}
}
pub(crate) const fn mixing_rule(self) -> NativeMixingRule {
match self {
Self::Argon2id => NativeMixingRule::exclusive(),
Self::X25519 => NativeMixingRule::public_key_mixable(),
}
}
pub(crate) const fn recipient_mode(self) -> crate::UnauthenticatedRecipientMode {
match self {
Self::Argon2id => crate::UnauthenticatedRecipientMode::Passphrase,
Self::X25519 => crate::UnauthenticatedRecipientMode::PublicKey,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum MixingPolicy {
Exclusive,
PublicKeyMixable,
Custom {
compatibility_class: &'static str,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NativeMixingRule {
SingleEntry,
Class {
name: &'static str,
},
}
impl NativeMixingRule {
pub(crate) const PUBLIC_KEY_CLASS: &'static str = "public-key";
#[allow(dead_code)] pub(crate) const POST_QUANTUM_CLASS: &'static str = "postquantum";
pub(crate) const fn exclusive() -> Self {
Self::SingleEntry
}
pub(crate) const fn public_key_mixable() -> Self {
Self::Class {
name: Self::PUBLIC_KEY_CLASS,
}
}
#[allow(dead_code)] pub(crate) const fn post_quantum() -> Self {
Self::Class {
name: Self::POST_QUANTUM_CLASS,
}
}
pub(crate) const fn requires_single_entry(self) -> bool {
matches!(self, Self::SingleEntry)
}
pub(crate) fn diagnostic_policy(self) -> MixingPolicy {
match self {
Self::SingleEntry => MixingPolicy::Exclusive,
Self::Class { name } if name == Self::PUBLIC_KEY_CLASS => {
MixingPolicy::PublicKeyMixable
}
Self::Class { name } => MixingPolicy::Custom {
compatibility_class: name,
},
}
}
}
pub(crate) fn enforce_recipient_mixing_policy(
entries: &[RecipientEntry],
) -> Result<(), CryptoError> {
let mut control: Option<(&RecipientEntry, NativeMixingRule)> = None;
for entry in entries {
let Some(ty) = NativeRecipientType::from_type_name(&entry.type_name) else {
continue;
};
let rule = ty.mixing_rule();
if rule.requires_single_entry() && entries.len() != 1 {
return Err(CryptoError::IncompatibleRecipients {
type_name: entry.type_name.clone(),
policy: rule.diagnostic_policy(),
});
}
match control {
None => control = Some((entry, rule)),
Some((first_entry, first_rule)) => match (first_rule, rule) {
(NativeMixingRule::Class { name: a }, NativeMixingRule::Class { name: b })
if a == b => {}
(NativeMixingRule::Class { .. }, NativeMixingRule::Class { .. }) => {
let (reported_entry, reported_rule) =
if rule.diagnostic_policy() != MixingPolicy::PublicKeyMixable {
(entry, rule)
} else {
(first_entry, first_rule)
};
return Err(CryptoError::IncompatibleRecipients {
type_name: reported_entry.type_name.clone(),
policy: reported_rule.diagnostic_policy(),
});
}
(NativeMixingRule::SingleEntry, _) | (_, NativeMixingRule::SingleEntry) => {
return Err(CryptoError::InternalInvariant(
"single-entry rule reached class comparison",
));
}
},
}
}
Ok(())
}
pub fn classify_recipient_mode(
entries: &[RecipientEntry],
) -> Result<crate::UnauthenticatedRecipientMode, CryptoError> {
for entry in entries {
match NativeRecipientType::from_type_name(&entry.type_name) {
Some(_native) => {
if entry.recipient_flags != 0 {
return Err(CryptoError::InvalidFormat(
FormatDefect::MalformedRecipientEntry,
));
}
}
None => {
if entry.is_critical() {
return Err(CryptoError::UnknownCriticalRecipient {
type_name: entry.type_name.clone(),
});
}
}
}
}
enforce_recipient_mixing_policy(entries)?;
let mut mode: Option<crate::UnauthenticatedRecipientMode> = None;
for entry in entries {
let Some(ty) = NativeRecipientType::from_type_name(&entry.type_name) else {
continue;
};
let entry_mode = ty.recipient_mode();
match mode {
None => mode = Some(entry_mode),
Some(existing) if existing == entry_mode => {}
Some(_) => {
return Err(CryptoError::IncompatibleRecipients {
type_name: entry.type_name.clone(),
policy: ty.mixing_rule().diagnostic_policy(),
});
}
}
}
mode.ok_or(CryptoError::NoSupportedRecipient)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::FormatDefect;
use crate::recipient::entry::RECIPIENT_FLAG_CRITICAL;
#[test]
fn known_recipient_type_round_trips_through_type_name() {
assert_eq!(
NativeRecipientType::from_type_name(argon2id::TYPE_NAME),
Some(NativeRecipientType::Argon2id)
);
assert_eq!(
NativeRecipientType::from_type_name(x25519::TYPE_NAME),
Some(NativeRecipientType::X25519)
);
assert_eq!(
NativeRecipientType::Argon2id.type_name(),
argon2id::TYPE_NAME
);
assert_eq!(NativeRecipientType::X25519.type_name(), x25519::TYPE_NAME);
}
#[test]
fn known_recipient_type_returns_none_for_unknown_names() {
assert_eq!(NativeRecipientType::from_type_name("scrypt"), None);
assert_eq!(NativeRecipientType::from_type_name("foo"), None);
assert_eq!(NativeRecipientType::from_type_name(""), None);
assert_eq!(NativeRecipientType::from_type_name("argon2"), None); }
#[test]
fn body_length_matches_per_type() {
assert_eq!(
NativeRecipientType::Argon2id.body_len(),
argon2id::BODY_LENGTH
);
assert_eq!(NativeRecipientType::X25519.body_len(), x25519::BODY_LENGTH);
}
#[test]
fn mixing_rule_per_native_type() {
let argon = NativeRecipientType::Argon2id.mixing_rule();
assert_eq!(argon, NativeMixingRule::SingleEntry);
assert!(argon.requires_single_entry());
assert_eq!(argon.diagnostic_policy(), MixingPolicy::Exclusive);
let x = NativeRecipientType::X25519.mixing_rule();
assert_eq!(
x,
NativeMixingRule::Class {
name: NativeMixingRule::PUBLIC_KEY_CLASS,
}
);
assert!(!x.requires_single_entry());
assert_eq!(x.diagnostic_policy(), MixingPolicy::PublicKeyMixable);
}
#[test]
fn post_quantum_rule_projects_to_custom_diagnostic() {
let rule = NativeMixingRule::post_quantum();
assert_eq!(
rule,
NativeMixingRule::Class {
name: NativeMixingRule::POST_QUANTUM_CLASS,
}
);
assert!(!rule.requires_single_entry());
assert_eq!(
rule.diagnostic_policy(),
MixingPolicy::Custom {
compatibility_class: NativeMixingRule::POST_QUANTUM_CLASS,
}
);
}
#[test]
fn recipient_mode_per_native_type() {
assert_eq!(
NativeRecipientType::Argon2id.recipient_mode(),
crate::UnauthenticatedRecipientMode::Passphrase
);
assert_eq!(
NativeRecipientType::X25519.recipient_mode(),
crate::UnauthenticatedRecipientMode::PublicKey
);
}
fn argon2id_entry() -> RecipientEntry {
RecipientEntry::native(
NativeRecipientType::Argon2id,
vec![0u8; argon2id::BODY_LENGTH],
)
.unwrap()
}
fn x25519_entry() -> RecipientEntry {
RecipientEntry::native(NativeRecipientType::X25519, vec![0u8; x25519::BODY_LENGTH]).unwrap()
}
fn unknown_entry(name: &str, critical: bool) -> RecipientEntry {
RecipientEntry {
type_name: name.to_string(),
recipient_flags: if critical { RECIPIENT_FLAG_CRITICAL } else { 0 },
body: vec![0u8; 8],
}
}
#[test]
fn native_constructor_rejects_wrong_body_length() {
let err =
RecipientEntry::native(NativeRecipientType::Argon2id, vec![0u8; 100]).unwrap_err();
match err {
CryptoError::InvalidFormat(FormatDefect::MalformedRecipientEntry) => {}
other => panic!("expected MalformedRecipientEntry, got {other:?}"),
}
}
#[test]
fn native_constructor_sets_canonical_type_name_and_zero_flags() {
let entry = argon2id_entry();
assert_eq!(entry.type_name, argon2id::TYPE_NAME);
assert_eq!(entry.recipient_flags, 0);
assert!(!entry.is_critical());
}
#[test]
fn enforce_mixing_accepts_lone_argon2id() {
enforce_recipient_mixing_policy(&[argon2id_entry()]).unwrap();
}
#[test]
fn enforce_mixing_accepts_multiple_x25519() {
enforce_recipient_mixing_policy(&[x25519_entry(), x25519_entry()]).unwrap();
}
fn assert_argon2id_mixing_violation(err: CryptoError) {
match err {
CryptoError::IncompatibleRecipients { type_name, policy } => {
assert_eq!(type_name, argon2id::TYPE_NAME);
assert_eq!(policy, MixingPolicy::Exclusive);
}
other => panic!("expected IncompatibleRecipients(argon2id, Exclusive), got {other:?}"),
}
}
#[test]
fn enforce_mixing_rejects_argon2id_plus_x25519() {
let err = enforce_recipient_mixing_policy(&[argon2id_entry(), x25519_entry()]).unwrap_err();
assert_argon2id_mixing_violation(err);
}
#[test]
fn enforce_mixing_rejects_x25519_plus_argon2id() {
let err = enforce_recipient_mixing_policy(&[x25519_entry(), argon2id_entry()]).unwrap_err();
assert_argon2id_mixing_violation(err);
}
#[test]
fn enforce_mixing_rejects_two_argon2ids() {
let err =
enforce_recipient_mixing_policy(&[argon2id_entry(), argon2id_entry()]).unwrap_err();
assert_argon2id_mixing_violation(err);
}
#[test]
fn classify_rejects_two_argon2ids() {
let err = classify_recipient_mode(&[argon2id_entry(), argon2id_entry()]).unwrap_err();
assert_argon2id_mixing_violation(err);
}
#[test]
fn enforce_mixing_rejects_argon2id_plus_unknown_non_critical() {
let err = enforce_recipient_mixing_policy(&[
argon2id_entry(),
unknown_entry("future-thing", false),
])
.unwrap_err();
assert_argon2id_mixing_violation(err);
}
#[test]
fn classify_returns_passphrase_for_lone_argon2id() {
let mode = classify_recipient_mode(&[argon2id_entry()]).unwrap();
assert_eq!(mode, crate::UnauthenticatedRecipientMode::Passphrase);
}
#[test]
fn classify_returns_public_key_for_x25519() {
let mode = classify_recipient_mode(&[x25519_entry()]).unwrap();
assert_eq!(mode, crate::UnauthenticatedRecipientMode::PublicKey);
}
#[test]
fn classify_returns_public_key_for_multiple_x25519() {
let mode = classify_recipient_mode(&[x25519_entry(), x25519_entry()]).unwrap();
assert_eq!(mode, crate::UnauthenticatedRecipientMode::PublicKey);
}
#[test]
fn classify_skips_unknown_non_critical_in_front_of_x25519() {
let mode = classify_recipient_mode(&[unknown_entry("future-thing", false), x25519_entry()])
.unwrap();
assert_eq!(mode, crate::UnauthenticatedRecipientMode::PublicKey);
}
#[test]
fn classify_rejects_unknown_critical() {
let err = classify_recipient_mode(&[unknown_entry("must-handle-me", true), x25519_entry()])
.unwrap_err();
match err {
CryptoError::UnknownCriticalRecipient { type_name } => {
assert_eq!(type_name, "must-handle-me");
}
other => panic!("expected UnknownCriticalRecipient, got {other:?}"),
}
}
#[test]
fn classify_rejects_argon2id_mixed_via_incompatible_recipients() {
let err = classify_recipient_mode(&[argon2id_entry(), x25519_entry()]).unwrap_err();
assert_argon2id_mixing_violation(err);
}
#[test]
fn classify_rejects_only_unknown_non_critical_with_no_supported_recipient() {
let err = classify_recipient_mode(&[unknown_entry("future-thing", false)]).unwrap_err();
assert!(matches!(err, CryptoError::NoSupportedRecipient));
}
#[test]
fn classify_rejects_empty_entry_list_with_no_supported_recipient() {
let err = classify_recipient_mode(&[]).unwrap_err();
assert!(matches!(err, CryptoError::NoSupportedRecipient));
}
#[test]
fn classify_rejects_native_argon2id_with_critical_bit() {
let bad = RecipientEntry {
type_name: argon2id::TYPE_NAME.to_owned(),
recipient_flags: RECIPIENT_FLAG_CRITICAL,
body: vec![0u8; argon2id::BODY_LENGTH],
};
let err = classify_recipient_mode(&[bad]).unwrap_err();
match err {
CryptoError::InvalidFormat(FormatDefect::MalformedRecipientEntry) => {}
other => {
panic!(
"expected MalformedRecipientEntry for native argon2id+critical, got {other:?}"
)
}
}
}
#[test]
fn classify_rejects_native_x25519_with_critical_bit() {
let bad = RecipientEntry {
type_name: x25519::TYPE_NAME.to_owned(),
recipient_flags: RECIPIENT_FLAG_CRITICAL,
body: vec![0u8; x25519::BODY_LENGTH],
};
let err = classify_recipient_mode(&[bad]).unwrap_err();
match err {
CryptoError::InvalidFormat(FormatDefect::MalformedRecipientEntry) => {}
other => {
panic!("expected MalformedRecipientEntry for native x25519+critical, got {other:?}")
}
}
}
#[test]
fn classify_rejects_when_native_critical_and_unknown_critical_both_present() {
let bad_native = RecipientEntry {
type_name: x25519::TYPE_NAME.to_owned(),
recipient_flags: RECIPIENT_FLAG_CRITICAL,
body: vec![0u8; x25519::BODY_LENGTH],
};
let unknown_crit = unknown_entry("future-critical", true);
let err = classify_recipient_mode(&[bad_native, unknown_crit]).unwrap_err();
match err {
CryptoError::InvalidFormat(FormatDefect::MalformedRecipientEntry)
| CryptoError::UnknownCriticalRecipient { .. } => {}
other => panic!(
"expected MalformedRecipientEntry or UnknownCriticalRecipient when both violations present, got {other:?}"
),
}
}
}