use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[non_exhaustive]
pub enum KeyType {
Ed25519,
X25519,
P256,
MlDsa44,
MlDsa65,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuantumPosture {
PostQuantumSigning,
ClassicalSigning,
ClassicalKeyAgreement,
}
impl QuantumPosture {
pub fn label(&self) -> &'static str {
match self {
QuantumPosture::PostQuantumSigning => "post-quantum signing",
QuantumPosture::ClassicalSigning => "classical signing",
QuantumPosture::ClassicalKeyAgreement => "classical key agreement",
}
}
pub fn is_quantum_resistant(&self) -> bool {
matches!(self, QuantumPosture::PostQuantumSigning)
}
}
impl KeyType {
pub fn posture(&self) -> QuantumPosture {
match self {
KeyType::Ed25519 | KeyType::P256 => QuantumPosture::ClassicalSigning,
KeyType::X25519 => QuantumPosture::ClassicalKeyAgreement,
KeyType::MlDsa44 | KeyType::MlDsa65 => QuantumPosture::PostQuantumSigning,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub enum KeyStatus {
Active,
Revoked,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub enum KeyOrigin {
Derived,
Imported,
Internal,
}
fn default_derived() -> KeyOrigin {
KeyOrigin::Derived
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct KeyRecord {
#[serde(alias = "key_id")]
pub key_id: String,
#[serde(alias = "derivation_path")]
pub derivation_path: String,
#[serde(alias = "key_type")]
pub key_type: KeyType,
pub status: KeyStatus,
#[serde(alias = "public_key")]
pub public_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, alias = "context_id", skip_serializing_if = "Option::is_none")]
pub context_id: Option<String>,
#[serde(default, alias = "seed_id", skip_serializing_if = "Option::is_none")]
pub seed_id: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exportable: Option<bool>,
#[serde(default = "default_derived")]
pub origin: KeyOrigin,
#[serde(alias = "created_at")]
pub created_at: DateTime<Utc>,
#[serde(alias = "updated_at")]
pub updated_at: DateTime<Utc>,
}
impl KeyType {
pub fn multicodec_public(&self) -> &'static [u8] {
match self {
KeyType::Ed25519 => &[0xed, 0x01], KeyType::X25519 => &[0xec, 0x01], KeyType::P256 => &[0x80, 0x24], KeyType::MlDsa44 => &[0x90, 0x24], KeyType::MlDsa65 => &[0x91, 0x24], }
}
pub fn multicodec_private(&self) -> &'static [u8] {
match self {
KeyType::Ed25519 => &[0x80, 0x26], KeyType::X25519 => &[0x82, 0x26], KeyType::P256 => &[0x86, 0x26], KeyType::MlDsa44 => &[0x9a, 0x26], KeyType::MlDsa65 => &[0x9b, 0x26], }
}
pub fn from_public_multibase(multibase_str: &str) -> Option<Self> {
let (_base, bytes) = multibase::decode(multibase_str).ok()?;
[
KeyType::Ed25519,
KeyType::X25519,
KeyType::P256,
KeyType::MlDsa44,
KeyType::MlDsa65,
]
.into_iter()
.find(|k| bytes.starts_with(k.multicodec_public()))
}
}
impl std::fmt::Display for KeyType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
KeyType::Ed25519 => write!(f, "ed25519"),
KeyType::X25519 => write!(f, "x25519"),
KeyType::P256 => write!(f, "p256"),
KeyType::MlDsa44 => write!(f, "mldsa44"),
KeyType::MlDsa65 => write!(f, "mldsa65"),
}
}
}
impl std::fmt::Display for KeyStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
KeyStatus::Active => write!(f, "active"),
KeyStatus::Revoked => write!(f, "revoked"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_matches_serde() {
for kt in [
KeyType::Ed25519,
KeyType::X25519,
KeyType::P256,
KeyType::MlDsa44,
KeyType::MlDsa65,
] {
let serde_spelling = serde_json::to_string(&kt).expect("serialises");
assert_eq!(
kt.to_string(),
serde_spelling.trim_matches('"'),
"Display and serde disagree for {kt:?}"
);
}
}
#[test]
fn key_type_wire_spellings_match_the_published_schema() {
for (kt, expected) in [
(KeyType::Ed25519, "ed25519"),
(KeyType::X25519, "x25519"),
(KeyType::P256, "p256"),
(KeyType::MlDsa44, "mldsa44"),
(KeyType::MlDsa65, "mldsa65"),
] {
assert_eq!(
serde_json::to_string(&kt).unwrap(),
format!("\"{expected}\"")
);
assert_eq!(
serde_json::from_str::<KeyType>(&format!("\"{expected}\"")).unwrap(),
kt
);
}
}
#[test]
fn multicodecs_are_distinct_and_registered() {
assert_eq!(KeyType::MlDsa44.multicodec_public(), &[0x90, 0x24]); assert_eq!(KeyType::MlDsa65.multicodec_public(), &[0x91, 0x24]); assert_eq!(KeyType::MlDsa44.multicodec_private(), &[0x9a, 0x26]); assert_eq!(KeyType::MlDsa65.multicodec_private(), &[0x9b, 0x26]);
let all = [
KeyType::Ed25519,
KeyType::X25519,
KeyType::P256,
KeyType::MlDsa44,
KeyType::MlDsa65,
];
for (i, a) in all.iter().enumerate() {
for b in &all[i + 1..] {
assert_ne!(
a.multicodec_public(),
b.multicodec_public(),
"{a:?} and {b:?} share a public codec"
);
assert_ne!(
a.multicodec_private(),
b.multicodec_private(),
"{a:?} and {b:?} share a private codec"
);
}
}
}
#[test]
fn the_two_ml_dsa_parameter_sets_are_distinct() {
assert_ne!(KeyType::MlDsa44, KeyType::MlDsa65);
assert_ne!(KeyType::MlDsa44.to_string(), KeyType::MlDsa65.to_string());
}
}
#[cfg(test)]
mod posture_tests {
use super::{KeyType, QuantumPosture};
#[test]
fn each_key_type_answers_the_axis_it_can() {
for (key_type, expected) in [
(KeyType::Ed25519, QuantumPosture::ClassicalSigning),
(KeyType::P256, QuantumPosture::ClassicalSigning),
(KeyType::X25519, QuantumPosture::ClassicalKeyAgreement),
(KeyType::MlDsa44, QuantumPosture::PostQuantumSigning),
(KeyType::MlDsa65, QuantumPosture::PostQuantumSigning),
] {
assert_eq!(key_type.posture(), expected, "{key_type:?}");
}
}
#[test]
fn only_ml_dsa_is_quantum_resistant() {
assert!(KeyType::MlDsa44.posture().is_quantum_resistant());
assert!(KeyType::MlDsa65.posture().is_quantum_resistant());
assert!(!KeyType::Ed25519.posture().is_quantum_resistant());
assert!(!KeyType::P256.posture().is_quantum_resistant());
assert!(!KeyType::X25519.posture().is_quantum_resistant());
}
#[test]
fn every_label_names_its_axis() {
for posture in [
QuantumPosture::PostQuantumSigning,
QuantumPosture::ClassicalSigning,
QuantumPosture::ClassicalKeyAgreement,
] {
let label = posture.label();
assert!(
label.contains("signing") || label.contains("key agreement"),
"'{label}' does not say which axis it is about"
);
}
}
}