a1/identity.rs
1pub mod narrowing;
2pub mod receipt;
3
4use ed25519_dalek::{Signature, Signer as DalekSigner, SigningKey, VerifyingKey};
5use rand::rngs::OsRng;
6use zeroize::ZeroizeOnDrop;
7
8/// Abstraction over an Ed25519 signing backend.
9///
10/// Implement this trait to integrate hardware security modules (HSMs),
11/// cloud key management services (AWS KMS, Azure Key Vault, HashiCorp Vault,
12/// Google Cloud KMS), or any other backend that can produce Ed25519 signatures
13/// without exposing raw private key bytes.
14///
15/// The in-process [`DyoloIdentity`] implements this trait and is the
16/// recommended default for development and testing. Production deployments
17/// should implement `Signer` over their KMS of choice.
18///
19/// # Security contract
20///
21/// Implementors MUST ensure that:
22/// - `verifying_key()` always returns the public key matching the private key
23/// used by `sign_message()`.
24/// - `sign_message()` MUST NOT pre-hash `msg`; the caller already applies
25/// domain separation before calling this function.
26///
27/// # Note on Async SDKs
28///
29/// If your KMS SDK is asynchronous (like `aws-sdk-kms` or Google Cloud KMS),
30/// do **not** block the thread using `block_on` inside this trait. Doing so
31/// can cause deadlocks on single-threaded runtimes and thread-pool exhaustion
32/// on multi-threaded ones.
33///
34/// Instead, implement the [`AsyncSigner`] trait and use `CertBuilder::sign_async`.
35///
36/// # Example — HashiCorp Vault Transit skeleton
37///
38/// ```rust,ignore
39/// use a1::Signer;
40/// use base64::{engine::general_purpose, Engine as _};
41///
42/// struct VaultSigner { vault_addr: String, key_name: String, public_key: VerifyingKey }
43///
44/// impl Signer for VaultSigner {
45/// fn verifying_key(&self) -> VerifyingKey { self.public_key }
46///
47/// fn sign_message(&self, msg: &[u8]) -> ed25519_dalek::Signature {
48/// let encoded = general_purpose::STANDARD.encode(msg);
49/// // POST /v1/transit/sign/{key_name} with {"input": encoded}
50/// // Parse "data.signature" from response, strip "vault:v1:" prefix,
51/// // base64-decode, convert to [u8; 64], return Signature::from_bytes(...)
52/// todo!()
53/// }
54/// }
55/// ```
56pub trait Signer: Send + Sync {
57 /// Return the Ed25519 public key corresponding to this signing backend.
58 fn verifying_key(&self) -> VerifyingKey;
59
60 /// Produce an Ed25519 signature over `msg`.
61 ///
62 /// `msg` is the raw domain-separated bytes produced internally by
63 /// [`DelegationCert::signable_bytes`] — never a pre-hashed digest.
64 ///
65 /// [`DelegationCert::signable_bytes`]: crate::cert::DelegationCert::signable_bytes
66 fn sign_message(&self, msg: &[u8]) -> Signature;
67}
68
69/// Asynchronous abstraction over an Ed25519 signing backend.
70///
71/// Use this trait to integrate cloud key management services (AWS KMS, Azure
72/// Key Vault, HashiCorp Vault, Google Cloud KMS) whose SDKs are strictly async.
73#[cfg(feature = "async")]
74#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
75#[async_trait::async_trait]
76pub trait AsyncSigner: Send + Sync {
77 /// Return the Ed25519 public key corresponding to this signing backend.
78 fn verifying_key(&self) -> VerifyingKey;
79
80 /// Produce an Ed25519 signature over `msg` asynchronously.
81 async fn sign_message(&self, msg: &[u8]) -> Signature;
82}
83
84/// In-process Ed25519 identity for development, testing, and single-process
85/// deployments.
86///
87/// The signing key is held in process memory and zeroized on drop.
88/// For production deployments at scale, implement [`Signer`] over your
89/// HSM or KMS so private key material never touches application memory.
90///
91/// # Cloning
92///
93/// This struct intentionally does **not** implement `Clone`. Copying private
94/// key material across memory locations defeats `ZeroizeOnDrop` protections.
95/// If you need to share an identity across multiple threads or async tasks,
96/// use the provided [`SharedIdentity`] convenience wrapper instead.
97#[derive(ZeroizeOnDrop)]
98pub struct DyoloIdentity {
99 signing_key: SigningKey,
100}
101
102impl DyoloIdentity {
103 /// Generate a new random identity using the OS entropy source.
104 pub fn generate() -> Self {
105 Self {
106 signing_key: SigningKey::generate(&mut OsRng),
107 }
108 }
109
110 /// Restore an identity from a 32-byte signing key seed.
111 ///
112 /// # Security
113 ///
114 /// Source these bytes from secure storage (e.g. an HSM export, an
115 /// encrypted secrets manager, or a PKCS#8 file). Zeroize the input
116 /// buffer after calling this function.
117 ///
118 /// ```rust,ignore
119 /// use zeroize::Zeroize;
120 /// let mut seed = fetch_seed_from_vault();
121 /// let identity = DyoloIdentity::from_signing_bytes(&seed);
122 /// seed.zeroize();
123 /// ```
124 pub fn from_signing_bytes(bytes: &[u8; 32]) -> Self {
125 Self {
126 signing_key: SigningKey::from_bytes(bytes),
127 }
128 }
129
130 /// Export the raw 32-byte signing key seed.
131 ///
132 /// # Security
133 ///
134 /// The returned value is a secret. Zeroize it after use.
135 /// Do not log, store unencrypted, or transmit across a network.
136 pub fn to_signing_bytes(&self) -> [u8; 32] {
137 self.signing_key.to_bytes()
138 }
139
140 /// The Ed25519 public key for this identity.
141 pub fn verifying_key(&self) -> VerifyingKey {
142 self.signing_key.verifying_key()
143 }
144
145 #[allow(dead_code)]
146 pub(crate) fn sign(&self, message: &[u8]) -> Signature {
147 self.signing_key.sign(message)
148 }
149}
150
151impl Signer for DyoloIdentity {
152 fn verifying_key(&self) -> VerifyingKey {
153 self.signing_key.verifying_key()
154 }
155
156 fn sign_message(&self, msg: &[u8]) -> Signature {
157 self.signing_key.sign(msg)
158 }
159}
160
161impl std::fmt::Debug for DyoloIdentity {
162 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163 let vk = self.verifying_key();
164 let b = vk.as_bytes();
165 write!(
166 f,
167 "DyoloIdentity(vk:{:02x}{:02x}{:02x}{:02x}…)",
168 b[0], b[1], b[2], b[3]
169 )
170 }
171}
172
173/// A thread-safe, clonable reference to a [`DyoloIdentity`].
174///
175/// Since [`DyoloIdentity`] cannot be cloned (to preserve zeroization guarantees),
176/// `SharedIdentity` provides an `Arc` wrapper that implements `Clone` and `Signer`.
177/// Useful for passing a single identity into multiple async workers.
178#[derive(Clone, Debug)]
179pub struct SharedIdentity(pub std::sync::Arc<DyoloIdentity>);
180
181impl Signer for SharedIdentity {
182 fn verifying_key(&self) -> VerifyingKey {
183 self.0.verifying_key()
184 }
185
186 fn sign_message(&self, msg: &[u8]) -> Signature {
187 Signer::sign_message(&*self.0, msg)
188 }
189}
190
191#[cfg(feature = "async")]
192#[async_trait::async_trait]
193impl AsyncSigner for SharedIdentity {
194 fn verifying_key(&self) -> VerifyingKey {
195 self.0.verifying_key()
196 }
197
198 async fn sign_message(&self, msg: &[u8]) -> Signature {
199 Signer::sign_message(&*self.0, msg)
200 }
201}
202
203#[cfg(feature = "async")]
204#[async_trait::async_trait]
205impl AsyncSigner for DyoloIdentity {
206 fn verifying_key(&self) -> VerifyingKey {
207 self.verifying_key()
208 }
209
210 async fn sign_message(&self, msg: &[u8]) -> Signature {
211 Signer::sign_message(self, msg)
212 }
213}
214
215/// A trait for HTTP clients to transmit signing requests to a KMS.
216#[cfg(feature = "async")]
217#[async_trait::async_trait]
218pub trait KmsHttpClient: Send + Sync {
219 /// Post a payload to the KMS endpoint and return the raw response bytes.
220 async fn post(
221 &self,
222 url: &str,
223 headers: &[(&str, &str)],
224 body: &[u8],
225 ) -> Result<Vec<u8>, crate::error::A1Error>;
226}
227
228/// Production-ready HashiCorp Vault Transit backend for A1 Passports.
229///
230/// Ensures private keys never touch application memory. Signatures are strictly
231/// bound to the `v2.8.0` cryptographic domain natively via the Vault `context`.
232#[cfg(feature = "async")]
233pub struct VaultSigner {
234 vault_addr: String,
235 token: String,
236 key_name: String,
237 public_key: VerifyingKey,
238 http_client: Box<dyn KmsHttpClient>,
239}
240
241#[cfg(feature = "async")]
242impl std::fmt::Debug for VaultSigner {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 f.debug_struct("VaultSigner")
245 .field("vault_addr", &self.vault_addr)
246 .field("key_name", &self.key_name)
247 .field("public_key", &hex::encode(self.public_key.as_bytes()))
248 .finish()
249 }
250}
251
252#[cfg(feature = "async")]
253impl VaultSigner {
254 pub fn new(
255 vault_addr: String,
256 token: String,
257 key_name: String,
258 public_key_hex: &str,
259 http_client: Box<dyn KmsHttpClient>,
260 ) -> Result<Self, crate::error::A1Error> {
261 let pk_bytes = hex::decode(public_key_hex)
262 .map_err(|_| crate::error::A1Error::WireFormatError("invalid hex".into()))?;
263 let public_key = VerifyingKey::from_bytes(
264 &pk_bytes
265 .try_into()
266 .map_err(|_| crate::error::A1Error::WireFormatError("must be 32 bytes".into()))?,
267 )
268 .map_err(|_| crate::error::A1Error::WireFormatError("invalid ed25519 key".into()))?;
269
270 Ok(Self {
271 vault_addr,
272 token,
273 key_name,
274 public_key,
275 http_client,
276 })
277 }
278}
279
280#[cfg(feature = "async")]
281#[async_trait::async_trait]
282impl AsyncSigner for VaultSigner {
283 fn verifying_key(&self) -> VerifyingKey {
284 self.public_key
285 }
286
287 async fn sign_message(&self, msg: &[u8]) -> Signature {
288 // Vault expects base64 encoded input for the transit engine
289 use base64::{engine::general_purpose, Engine as _};
290 let encoded_input = general_purpose::STANDARD.encode(msg);
291
292 // Structure the Vault Transit API request.
293 // The context parameter cryptographically binds the operation to the enforcer domain
294 // and persistently records the invocation marker in enterprise Vault audit logs.
295 let payload = format!(
296 "{{\"input\": \"{}\", \"context\": \"ZHlvbG9fdjIuOC4w\"}}",
297 encoded_input
298 );
299 let url = format!("{}/v1/transit/sign/{}", self.vault_addr, self.key_name);
300 let headers = [
301 ("X-Vault-Token", self.token.as_str()),
302 ("Content-Type", "application/json"),
303 ];
304
305 let resp_bytes = self
306 .http_client
307 .post(&url, &headers, payload.as_bytes())
308 .await
309 .expect("vault KMS post failed");
310
311 let resp_json: serde_json::Value =
312 serde_json::from_slice(&resp_bytes).expect("vault returned invalid JSON");
313
314 let sig_b64 = resp_json["data"]["signature"]
315 .as_str()
316 .expect("vault response missing data.signature")
317 .trim_start_matches("vault:v1:");
318
319 let sig_bytes = general_purpose::STANDARD
320 .decode(sig_b64)
321 .expect("vault signature base64 decode failed");
322
323 Signature::from_bytes(
324 &sig_bytes
325 .try_into()
326 .expect("vault signature must be 64 bytes"),
327 )
328 }
329}