Skip to main content

libfcp_core/
identity.rs

1// Copyright Nixort <https://github.com/Nixort> 2026.
2//
3// License: GNU General Public License v3.0 only.
4// You can find the license file in the project root.
5//
6// Federated CFR Connect Protocol (FCP).
7
8//! Endpoint identities and mandatory dual-signature authentication.
9
10use ed25519_dalek::{Signer as Ed25519Signer, SigningKey, VerifyingKey};
11use ml_dsa::{
12    Keypair, MlDsa65, Signature, SigningKey as MlDsaSigningKey, Verifier,
13    VerifyingKey as MlDsaVerifyingKey,
14};
15
16use crate::{EndpointKey, Error};
17
18/// FIPS 204 ML-DSA-65 encoded public-key width.
19pub const ML_DSA_65_PUBLIC_KEY_BYTES: usize = 1_952;
20/// FIPS 204 ML-DSA-65 encoded signature width.
21pub const ML_DSA_65_SIGNATURE_BYTES: usize = 3_309;
22
23/// An immutable FCP endpoint identity.
24///
25/// The identity is the atomic binding of the classical and post-quantum
26/// verification keys. Configurations, connections, and envelope addressing
27/// always carry the complete binding.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub struct EndpointIdentity {
30    /// Ed25519 verification key.
31    pub classical: EndpointKey,
32    /// ML-DSA-65 verification key in fixed FIPS 204 encoding.
33    pub post_quantum: [u8; ML_DSA_65_PUBLIC_KEY_BYTES],
34}
35
36impl EndpointIdentity {
37    /// Constructs a complete endpoint identity from canonical key encodings.
38    #[must_use]
39    pub const fn new(
40        classical: EndpointKey,
41        post_quantum: [u8; ML_DSA_65_PUBLIC_KEY_BYTES],
42    ) -> Self {
43        Self {
44            classical,
45            post_quantum,
46        }
47    }
48
49    /// Verifies the mandatory Ed25519 signature over `message`.
50    pub fn verify_classical(&self, message: &[u8], signature: &[u8; 64]) -> Result<(), Error> {
51        let key =
52            VerifyingKey::from_bytes(self.classical.as_bytes()).map_err(|_| Error::BadSenderKey)?;
53        key.verify_strict(message, &ed25519_dalek::Signature::from_bytes(signature))
54            .map_err(|_| Error::BadSignature)
55    }
56
57    /// Verifies the mandatory ML-DSA-65 signature over `message`.
58    pub fn verify_post_quantum(
59        &self,
60        message: &[u8],
61        signature: &[u8; ML_DSA_65_SIGNATURE_BYTES],
62    ) -> Result<(), Error> {
63        let key_bytes =
64            ml_dsa::EncodedVerifyingKey::<MlDsa65>::try_from(self.post_quantum.as_slice())
65                .map_err(|_| Error::BadPostQuantumKey)?;
66        let key = MlDsaVerifyingKey::<MlDsa65>::decode(&key_bytes);
67        let signature = Signature::<MlDsa65>::try_from(signature.as_slice())
68            .map_err(|_| Error::BadPostQuantumSignatureEncoding)?;
69        key.verify(message, &signature)
70            .map_err(|_| Error::BadPostQuantumSignature)
71    }
72}
73
74/// An implementation-owned signing identity with both required private keys.
75///
76/// No runtime randomness is needed to use this type. Applications may derive
77/// or securely load the two signing keys according to their own key-management
78/// policy before constructing the identity.
79pub struct SigningIdentity {
80    classical: SigningKey,
81    post_quantum: MlDsaSigningKey<MlDsa65>,
82}
83
84impl SigningIdentity {
85    /// Combines application-managed Ed25519 and ML-DSA-65 signing keys.
86    #[must_use]
87    pub fn new(classical: SigningKey, post_quantum: MlDsaSigningKey<MlDsa65>) -> Self {
88        Self {
89            classical,
90            post_quantum,
91        }
92    }
93
94    /// Returns the stable public endpoint identity corresponding to these keys.
95    #[must_use]
96    pub fn endpoint(&self) -> EndpointIdentity {
97        let encoded = self.post_quantum.verifying_key().encode();
98        let mut post_quantum = [0_u8; ML_DSA_65_PUBLIC_KEY_BYTES];
99        post_quantum.copy_from_slice(encoded.as_ref());
100        EndpointIdentity::new(
101            EndpointKey::from_bytes(self.classical.verifying_key().to_bytes()),
102            post_quantum,
103        )
104    }
105}
106
107/// An endpoint capable of producing both mandatory FCP signatures.
108pub trait EndpointSigner {
109    /// Returns the complete identity used to address and authenticate envelopes.
110    fn endpoint(&self) -> EndpointIdentity;
111    /// Produces the canonical Ed25519 signature for the supplied transcript.
112    fn sign_classical(&self, message: &[u8]) -> [u8; 64];
113    /// Produces the canonical ML-DSA-65 signature for the supplied transcript.
114    fn sign_post_quantum(&self, message: &[u8]) -> [u8; ML_DSA_65_SIGNATURE_BYTES];
115}
116
117impl EndpointSigner for SigningIdentity {
118    fn endpoint(&self) -> EndpointIdentity {
119        self.endpoint()
120    }
121
122    fn sign_classical(&self, message: &[u8]) -> [u8; 64] {
123        Ed25519Signer::sign(&self.classical, message).to_bytes()
124    }
125
126    fn sign_post_quantum(&self, message: &[u8]) -> [u8; ML_DSA_65_SIGNATURE_BYTES] {
127        let encoded = self.post_quantum.sign(message).encode();
128        let mut signature = [0_u8; ML_DSA_65_SIGNATURE_BYTES];
129        signature.copy_from_slice(encoded.as_ref());
130        signature
131    }
132}