agentplane/policy/signing.rs
1//! Ed25519 signing and verification, for deployments without their own.
2//!
3//! Behind the `signing` feature. The seam in [`core::attest`](crate::core) is
4//! always available — this is one implementation of it, and a deployment with a
5//! workload-identity system should plug that in instead. What is here exists so
6//! that "sign the journal" is not blocked on procuring a PKI.
7//!
8//! # Why Ed25519 and not a choice
9//!
10//! Because there is nothing to configure. No curve, no padding mode, no hash
11//! agility, no parameter that can be selected badly. The signature is 64 bytes,
12//! signing is fast enough to sit on the journal's write path, and verification
13//! needs only the public key.
14//!
15//! The [`KeySignature`](crate::core::KeySignature) deliberately carries no
16//! algorithm field. Self-described algorithms are how a verifier gets talked
17//! into checking a signature with something weaker than the one that made it;
18//! here the verifier decides what it will accept, and a record it cannot check
19//! is a record it rejects.
20//!
21//! # Keys come from outside
22//!
23//! [`Ed25519Signer`] is constructed from key bytes the deployment supplies. It
24//! cannot generate one, and that is not an oversight: a plane that mints its own
25//! identity produces records that look attested and prove nothing, because the
26//! party being audited chose the key. `rand_core` is switched off in the
27//! dependency for exactly this reason.
28
29use std::fmt::Debug;
30
31use ed25519_dalek::{Signature, Signer as _, SigningKey, Verifier as _, VerifyingKey};
32
33use crate::core::{Digest, KeyId, Signer, Verifier};
34
35/// Signs records with an Ed25519 key.
36///
37/// The secret never appears in `Debug` — same rule as a peer credential, and for
38/// the same reason: a key that can be printed is a key in a log line, and this
39/// one is held for the process's lifetime.
40pub struct Ed25519Signer {
41 key: SigningKey,
42 key_id: KeyId,
43}
44
45impl Debug for Ed25519Signer {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 f.debug_struct("Ed25519Signer")
48 .field("key_id", &self.key_id)
49 .field("secret", &"<redacted>")
50 .finish_non_exhaustive()
51 }
52}
53
54impl Ed25519Signer {
55 /// Build from a 32-byte seed the deployment supplies.
56 ///
57 /// `key_id` is what lands on every record. Give it the workload's real
58 /// name — a SPIFFE ID if there is one — because "some key signed this" is a
59 /// much weaker statement than "this workload signed this", and the second is
60 /// what an audit is asking.
61 #[must_use]
62 pub fn new(key_id: impl Into<KeyId>, seed: &[u8; 32]) -> Self {
63 Self {
64 key: SigningKey::from_bytes(seed),
65 key_id: key_id.into(),
66 }
67 }
68
69 /// The public half, for handing to a verifier.
70 #[must_use]
71 pub fn verifying_key(&self) -> [u8; 32] {
72 self.key.verifying_key().to_bytes()
73 }
74}
75
76impl Signer for Ed25519Signer {
77 fn key_id(&self) -> KeyId {
78 self.key_id.clone()
79 }
80
81 fn sign(&self, hash: &Digest) -> Vec<u8> {
82 self.key.sign(hash.as_bytes()).to_bytes().to_vec()
83 }
84}
85
86/// Signing a checkpoint is signing **bytes**, for the same reason a card is.
87///
88/// C2SP `signed-note` specifies pure Ed25519 over the note text. Signing
89/// `SHA-256(note)` instead — which is what a digest-shaped signer does —
90/// produces 64 bytes that verify against nothing any witness, auditor or
91/// `signed-note` tool will compute.
92#[async_trait::async_trait]
93impl crate::core::CheckpointSigner for Ed25519Signer {
94 fn key_id(&self) -> KeyId {
95 self.key_id.clone()
96 }
97
98 async fn sign(&self, message: &[u8]) -> Result<Vec<u8>, crate::core::SignError> {
99 Ok(self.key.sign(message).to_bytes().to_vec())
100 }
101}
102
103/// Verifies records against a set of known public keys.
104///
105/// A key the set does not contain fails verification rather than erroring: an
106/// unknown signer and a bad signature are the same answer to the only question
107/// being asked, which is *may I believe this record*. Distinguishing them would
108/// tell a prober which key ids exist.
109#[derive(Debug, Default)]
110pub struct Ed25519Verifier {
111 keys: std::collections::BTreeMap<KeyId, VerifyingKey>,
112}
113
114impl Ed25519Verifier {
115 #[must_use]
116 pub fn new() -> Self {
117 Self::default()
118 }
119
120 /// Trust records signed by this key.
121 ///
122 /// # Errors
123 ///
124 /// If the bytes are not a valid Ed25519 public key, or are one of small
125 /// order — a weak key, under which a signature verifies without anybody's
126 /// secret.
127 pub fn trust(
128 mut self,
129 key_id: impl Into<KeyId>,
130 public: &[u8; 32],
131 ) -> Result<Self, ed25519_dalek::SignatureError> {
132 let key = VerifyingKey::from_bytes(public)?;
133 if key.is_weak() {
134 return Err(ed25519_dalek::SignatureError::new());
135 }
136 self.keys.insert(key_id.into(), key);
137 Ok(self)
138 }
139}
140
141impl Verifier for Ed25519Verifier {
142 fn verify(&self, key_id: &str, hash: &Digest, signature: &[u8]) -> bool {
143 let Some(key) = self.keys.get(key_id) else {
144 return false;
145 };
146 let Ok(bytes) = <[u8; 64]>::try_from(signature) else {
147 return false;
148 };
149 key.verify(hash.as_bytes(), &Signature::from_bytes(&bytes))
150 .is_ok()
151 }
152}
153
154/// Signing a card is signing **bytes**, not a digest.
155///
156/// The same key as the record signer, a different message shape: RFC 7515 says
157/// the signature covers the JWS signing input itself. Signing its hash instead
158/// would verify perfectly here and nowhere else.
159#[cfg(feature = "manifest")]
160impl crate::peers::CardSigner for Ed25519Signer {
161 fn key_id(&self) -> KeyId {
162 self.key_id.clone()
163 }
164
165 fn sign_bytes(&self, message: &[u8]) -> Vec<u8> {
166 self.key.sign(message).to_bytes().to_vec()
167 }
168}
169
170#[cfg(feature = "manifest")]
171impl crate::peers::CardVerifier for Ed25519Verifier {
172 fn verify_bytes(&self, key_id: &str, message: &[u8], signature: &[u8]) -> bool {
173 let Some(key) = self.keys.get(key_id) else {
174 return false;
175 };
176 let Ok(bytes) = <[u8; 64]>::try_from(signature) else {
177 return false;
178 };
179 key.verify(message, &Signature::from_bytes(&bytes)).is_ok()
180 }
181}