Skip to main content

secretx_local_signing/
lib.rs

1//! Local file-based signing backend for secretx.
2//!
3//! Loads a private key from a file (PKCS#8 DER) and implements [`SigningBackend`]
4//! for Ed25519, ECDSA P-256/SHA-256, and RSA-PSS-2048/SHA-256.
5//!
6//! # URI format
7//!
8//! ```text
9//! secretx:local-signing:<key_path>[?algorithm=<algo>]
10//! ```
11//!
12//! Where `<algo>` is one of `ed25519`, `p256`, or `rsa-pss-2048`, and
13//! `<key_path>` is the path to the PKCS#8 DER-encoded private key file.
14//! When `?algorithm=` is omitted the algorithm is auto-detected from the
15//! PKCS#8 DER `AlgorithmIdentifier` OID embedded in the key file.
16//! Use a leading `/` for absolute paths:
17//!
18//! ```text
19//! secretx:local-signing:/etc/secrets/ed25519.der
20//! secretx:local-signing:/etc/secrets/ed25519.der?algorithm=ed25519
21//! secretx:local-signing:relative/key.der?algorithm=p256
22//! secretx:local-signing:/etc/secrets/rsa.der?algorithm=rsa-pss-2048
23//! ```
24//!
25//! # Example
26//!
27//! ```rust,no_run
28//! # async fn example() -> Result<(), secretx_core::SecretError> {
29//! use secretx_local_signing::LocalSigningBackend;
30//! use secretx_core::SigningBackend;
31//!
32//! let backend = LocalSigningBackend::from_uri(
33//!     "secretx:local-signing:/etc/secrets/ed25519.der?algorithm=ed25519",
34//! )?;
35//! let sig = backend.sign(b"hello world").await?;
36//! # Ok(())
37//! # }
38//! ```
39
40use std::sync::Arc;
41
42use ed25519_dalek::pkcs8::DecodePrivateKey as Ed25519DecodePrivateKey;
43use secretx_core::{SecretError, SecretUri, SigningAlgorithm, SigningBackend};
44
45const BACKEND: &str = "local-signing";
46use signature::{SignatureEncoding, Signer};
47use zeroize::Zeroizing;
48
49/// Read a file into a [`Zeroizing<Vec<u8>>`], pre-sized from file metadata to
50/// avoid reallocation-induced leaks of partial secret key bytes.
51fn read_file_zeroizing(path: &str) -> std::io::Result<Zeroizing<Vec<u8>>> {
52    use std::io::Read;
53    let mut f = std::fs::File::open(path)?;
54    let len = f.metadata().map(|m| m.len() as usize).unwrap_or(0);
55    let mut buf = Zeroizing::new(Vec::with_capacity(len));
56    f.read_to_end(&mut buf)?;
57    Ok(buf)
58}
59
60// ── Key storage ───────────────────────────────────────────────────────────────
61
62enum LocalKey {
63    Ed25519(ed25519_dalek::SigningKey),
64    P256(p256::ecdsa::SigningKey),
65    // Wrapped in Arc so sign() can clone the Arc (a pointer copy) for
66    // spawn_blocking rather than cloning the full RSA key material on every
67    // call.  SigningKey<Sha256> implements ZeroizeOnDrop, so the key is
68    // zeroed when the last Arc drops — either when LocalSigningBackend is
69    // dropped (after any in-flight sign tasks complete) or when the sign
70    // task's clone drops, whichever is last.
71    RsaPss2048(std::sync::Arc<rsa::pss::SigningKey<sha2::Sha256>>),
72}
73
74// ── Backend ───────────────────────────────────────────────────────────────────
75
76/// Signing backend that loads a private key from a local file.
77///
78/// Construct with [`LocalSigningBackend::from_uri`].
79pub struct LocalSigningBackend {
80    inner: LocalKey,
81    algorithm: SigningAlgorithm,
82}
83
84impl std::fmt::Debug for LocalSigningBackend {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.debug_struct("LocalSigningBackend")
87            .field("algorithm", &self.algorithm)
88            .finish_non_exhaustive()
89    }
90}
91
92impl LocalSigningBackend {
93    /// Construct from a `secretx:local-signing:<path>[?algorithm=<algo>]` URI.
94    ///
95    /// Reads and parses the key file eagerly. Does not retain raw key bytes
96    /// after construction — they are zeroed when the local `Zeroizing` buffer
97    /// is dropped.
98    ///
99    /// When `?algorithm=` is omitted the algorithm is auto-detected by trying
100    /// each supported PKCS#8 decoder (Ed25519, P-256, RSA) in order.
101    ///
102    /// # Errors
103    ///
104    /// - [`SecretError::InvalidUri`] — wrong backend, empty path, `..` components,
105    ///   unknown `?algorithm=` value, or unknown query parameters.
106    /// - [`SecretError::NotFound`] — key file does not exist.
107    /// - [`SecretError::Backend`] — key file exists but cannot be parsed as
108    ///   PKCS#8 DER for the requested (or any supported) algorithm.
109    pub fn from_uri(uri: &str) -> Result<Self, SecretError> {
110        Self::from_parsed_uri(&SecretUri::parse(uri)?)
111    }
112
113    /// Construct from a pre-parsed [`SecretUri`].
114    pub fn from_parsed_uri(parsed: &SecretUri) -> Result<Self, SecretError> {
115        if parsed.backend() != BACKEND {
116            return Err(SecretError::InvalidUri(format!(
117                "expected backend `local-signing`, got `{}`",
118                parsed.backend()
119            )));
120        }
121        if parsed.path().is_empty() {
122            return Err(SecretError::InvalidUri(
123                "local-signing URI requires a key path: \
124                 secretx:local-signing:<path>?algorithm=<algo>"
125                    .into(),
126            ));
127        }
128        // Reject .. components to prevent path traversal.
129        let key_path = std::path::PathBuf::from(parsed.path());
130        if key_path
131            .components()
132            .any(|c| c == std::path::Component::ParentDir)
133        {
134            return Err(SecretError::InvalidUri(
135                "local-signing URI path must not contain '..' components".into(),
136            ));
137        }
138        // Reject unknown query parameters to catch typos early.
139        for key in parsed.param_keys() {
140            if key != "algorithm" {
141                return Err(SecretError::InvalidUri(format!(
142                    "local-signing URI: unknown query parameter `{key}`; \
143                     only `?algorithm=` is supported"
144                )));
145            }
146        }
147
148        let algo_str = parsed.param("algorithm");
149
150        // Validate algorithm string before doing any I/O so unknown algorithms
151        // always return InvalidUri, not a Backend/NotFound error.
152        if let Some(a) = algo_str {
153            validate_algorithm(a)?;
154        }
155
156        // Read key bytes into a pre-sized Zeroizing buffer.  std::fs::read()
157        // grows its internal Vec, leaking partial key copies through freed
158        // allocations.  Pre-sizing from metadata avoids reallocation.
159        let key_bytes: Zeroizing<Vec<u8>> = read_file_zeroizing(parsed.path())
160            .map_err(|e| match e.kind() {
161                std::io::ErrorKind::NotFound => SecretError::NotFound,
162                _ => SecretError::Backend {
163                    backend: BACKEND,
164                    source: e.into(),
165                },
166            })?;
167
168        let (inner, algorithm) = match algo_str {
169            Some(a) => parse_key(a, &key_bytes)?,
170            None => detect_key(&key_bytes)?,
171        };
172        Ok(Self { inner, algorithm })
173    }
174}
175
176fn validate_algorithm(algo_str: &str) -> Result<(), SecretError> {
177    match algo_str {
178        "ed25519" | "p256" | "rsa-pss-2048" => Ok(()),
179        other => Err(SecretError::InvalidUri(format!(
180            "unknown algorithm `{other}`; supported: ed25519, p256, rsa-pss-2048"
181        ))),
182    }
183}
184
185fn parse_key(
186    algo_str: &str,
187    key_bytes: &[u8],
188) -> Result<(LocalKey, SigningAlgorithm), SecretError> {
189    match algo_str {
190        "ed25519" => {
191            let key = ed25519_dalek::SigningKey::from_pkcs8_der(key_bytes).map_err(|e| {
192                SecretError::Backend {
193                    backend: BACKEND,
194                    source: format!("ed25519 PKCS#8 parse error: {e}").into(),
195                }
196            })?;
197            Ok((LocalKey::Ed25519(key), SigningAlgorithm::Ed25519))
198        }
199        "p256" => {
200            use p256::pkcs8::DecodePrivateKey as _;
201            let key = p256::ecdsa::SigningKey::from_pkcs8_der(key_bytes).map_err(|e| {
202                SecretError::Backend {
203                    backend: BACKEND,
204                    source: format!("P-256 PKCS#8 parse error: {e}").into(),
205                }
206            })?;
207            Ok((LocalKey::P256(key), SigningAlgorithm::EcdsaP256Sha256))
208        }
209        "rsa-pss-2048" => {
210            use pkcs8::DecodePrivateKey as _;
211            let key = rsa::RsaPrivateKey::from_pkcs8_der(key_bytes).map_err(|e| {
212                SecretError::Backend {
213                    backend: BACKEND,
214                    source: format!("RSA PKCS#8 parse error: {e}").into(),
215                }
216            })?;
217            // Wrap in Arc so sign() can clone the Arc (pointer copy) rather
218            // than the full key material on every spawn_blocking call.
219            let signing_key = std::sync::Arc::new(rsa::pss::SigningKey::<sha2::Sha256>::new(key));
220            Ok((
221                LocalKey::RsaPss2048(signing_key),
222                SigningAlgorithm::RsaPss2048Sha256,
223            ))
224        }
225        // validate_algorithm() already rejected every other value before
226        // parse_key() is called, so this arm is unreachable.
227        other => unreachable!("algorithm `{other}` was already rejected by validate_algorithm"),
228    }
229}
230
231/// Auto-detect the algorithm from PKCS#8 DER key bytes.
232///
233/// Tries each supported decoder in order (Ed25519, P-256, RSA) and returns
234/// the first that succeeds.  This works because the PKCS#8
235/// `AlgorithmIdentifier` OID differs for each algorithm, so at most one
236/// decoder will accept a given key file.
237fn detect_key(key_bytes: &[u8]) -> Result<(LocalKey, SigningAlgorithm), SecretError> {
238    // Ed25519
239    if let Ok(key) = ed25519_dalek::SigningKey::from_pkcs8_der(key_bytes) {
240        return Ok((LocalKey::Ed25519(key), SigningAlgorithm::Ed25519));
241    }
242    // P-256
243    {
244        use p256::pkcs8::DecodePrivateKey as _;
245        if let Ok(key) = p256::ecdsa::SigningKey::from_pkcs8_der(key_bytes) {
246            return Ok((LocalKey::P256(key), SigningAlgorithm::EcdsaP256Sha256));
247        }
248    }
249    // RSA
250    {
251        use pkcs8::DecodePrivateKey as _;
252        if let Ok(key) = rsa::RsaPrivateKey::from_pkcs8_der(key_bytes) {
253            let signing_key =
254                std::sync::Arc::new(rsa::pss::SigningKey::<sha2::Sha256>::new(key));
255            return Ok((
256                LocalKey::RsaPss2048(signing_key),
257                SigningAlgorithm::RsaPss2048Sha256,
258            ));
259        }
260    }
261
262    Err(SecretError::Backend {
263        backend: BACKEND,
264        source: "key file is not valid PKCS#8 DER for any supported algorithm \
265                 (ed25519, p256, rsa-pss-2048)"
266            .into(),
267    })
268}
269
270// ── SigningBackend impl ───────────────────────────────────────────────────────
271
272#[async_trait::async_trait]
273impl SigningBackend for LocalSigningBackend {
274    async fn sign(&self, message: &[u8]) -> Result<Vec<u8>, SecretError> {
275        match &self.inner {
276            LocalKey::Ed25519(key) => {
277                let sig: ed25519_dalek::Signature = key.sign(message);
278                Ok(sig.to_bytes().to_vec())
279            }
280            LocalKey::P256(key) => {
281                let sig: p256::ecdsa::Signature = key.sign(message);
282                Ok(sig.to_bytes().to_vec())
283            }
284            LocalKey::RsaPss2048(signing_key) => {
285                // RSA-2048 PSS signing takes ~1-3 ms of CPU.  Running it on
286                // the tokio executor thread would block all other tasks for
287                // that duration, so it is offloaded to a blocking thread pool
288                // via spawn_blocking.  Ed25519 and P-256 are fast (~10 µs)
289                // and do not need this treatment.
290                // Cloning the Arc is a pointer copy — the RSA key material is
291                // not duplicated.
292                let key_clone = std::sync::Arc::clone(signing_key);
293                let msg = message.to_vec();
294                tokio::task::spawn_blocking(move || -> Vec<u8> {
295                    let sig: rsa::pss::Signature = key_clone.sign(&msg);
296                    sig.to_bytes().to_vec()
297                })
298                .await
299                .map_err(|e| SecretError::Backend {
300                    backend: BACKEND,
301                    source: format!("RSA sign task panicked: {e}").into(),
302                })
303            }
304        }
305    }
306
307    async fn public_key_der(&self) -> Result<Vec<u8>, SecretError> {
308        use pkcs8::EncodePublicKey;
309        match &self.inner {
310            LocalKey::Ed25519(key) => {
311                let vk = key.verifying_key();
312                vk.to_public_key_der()
313                    .map(|d| d.to_vec())
314                    .map_err(|e| SecretError::Backend {
315                        backend: BACKEND,
316                        source: format!("Ed25519 public key encode error: {e}").into(),
317                    })
318            }
319            LocalKey::P256(key) => {
320                let vk = key.verifying_key();
321                vk.to_public_key_der()
322                    .map(|d| d.to_vec())
323                    .map_err(|e| SecretError::Backend {
324                        backend: BACKEND,
325                        source: format!("P-256 public key encode error: {e}").into(),
326                    })
327            }
328            LocalKey::RsaPss2048(signing_key) => {
329                use rsa::pkcs8::EncodePublicKey as RsaEncodePublicKey;
330                // Deref through Arc → &SigningKey; then SigningKey::as_ref()
331                // → &RsaPrivateKey; RsaPublicKey implements From<&RsaPrivateKey>.
332                // The explicit UFCS is required to disambiguate from the
333                // blanket AsRef<Self> impl.
334                let sk: &rsa::pss::SigningKey<sha2::Sha256> = signing_key;
335                let priv_key = <rsa::pss::SigningKey<_> as AsRef<rsa::RsaPrivateKey>>::as_ref(sk);
336                let pub_key = rsa::RsaPublicKey::from(priv_key);
337                pub_key
338                    .to_public_key_der()
339                    .map(|d| d.to_vec())
340                    .map_err(|e| SecretError::Backend {
341                        backend: BACKEND,
342                        source: format!("RSA public key encode error: {e}").into(),
343                    })
344            }
345        }
346    }
347
348    fn algorithm(&self) -> Result<SigningAlgorithm, SecretError> {
349        Ok(self.algorithm)
350    }
351}
352
353inventory::submit!(secretx_core::SigningBackendRegistration::new(
354    "local-signing",
355    |uri: &secretx_core::SecretUri| {
356        let b = LocalSigningBackend::from_parsed_uri(uri)?;
357        Ok(Arc::new(b) as Arc<dyn secretx_core::SigningBackend>)
358    },
359));
360
361// ── Tests ─────────────────────────────────────────────────────────────────────
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    use ed25519_dalek::pkcs8::DecodePublicKey as Ed25519DecodePublicKey;
368    use ed25519_dalek::Verifier as Ed25519Verifier;
369    use p256::ecdsa::{signature::Verifier as P256Verifier, Signature as P256Signature, VerifyingKey as P256VerifyingKey};
370    use p256::pkcs8::DecodePublicKey as P256DecodePublicKey;
371    use rsa::pkcs8::DecodePublicKey as RsaDecodePublicKey;
372    use rsa::pss::VerifyingKey as RsaPssVerifyingKey;
373    use rsa::signature::Verifier as RsaVerifier;
374
375    // Test key paths — generated once with openssl and stored in /tmp at test time.
376    // These paths are stable for the test environment; CI must pre-generate them.
377    const ED25519_KEY: &str = "/tmp/secretx-test-keys/ed25519.der";
378    const P256_KEY: &str = "/tmp/secretx-test-keys/p256.der";
379    const RSA_KEY: &str = "/tmp/secretx-test-keys/rsa.der";
380
381    fn ed25519_uri() -> String {
382        format!("secretx:local-signing:{ED25519_KEY}?algorithm=ed25519")
383    }
384    fn p256_uri() -> String {
385        format!("secretx:local-signing:{P256_KEY}?algorithm=p256")
386    }
387    fn rsa_uri() -> String {
388        format!("secretx:local-signing:{RSA_KEY}?algorithm=rsa-pss-2048")
389    }
390
391    /// Load a backend from a URI, skipping the test (returning early) if the
392    /// key file is absent.  Panics on any other error so real failures are not
393    /// silently swallowed.
394    ///
395    /// Generate test keys once with:
396    /// ```text
397    /// mkdir -p /tmp/secretx-test-keys
398    /// openssl genpkey -algorithm ed25519 -outform DER \
399    ///     -out /tmp/secretx-test-keys/ed25519.der
400    /// openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 \
401    ///     -outform DER -out /tmp/secretx-test-keys/p256.der
402    /// openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
403    ///     -outform DER -out /tmp/secretx-test-keys/rsa.der
404    /// ```
405    /// Without those files the integration tests skip cleanly.
406    macro_rules! load_or_skip {
407        ($uri:expr) => {
408            match LocalSigningBackend::from_uri(&$uri) {
409                Ok(b) => b,
410                Err(SecretError::NotFound) => return, // key files absent; skip
411                Err(e) => panic!("from_uri failed unexpectedly: {e}"),
412            }
413        };
414    }
415
416    // ── from_uri parsing ──────────────────────────────────────────────────────
417
418    #[test]
419    fn from_uri_wrong_backend() {
420        assert!(matches!(
421            LocalSigningBackend::from_uri("secretx:file:/tmp/key.der?algorithm=ed25519"),
422            Err(SecretError::InvalidUri(_))
423        ));
424    }
425
426    #[test]
427    fn from_uri_missing_path() {
428        assert!(matches!(
429            LocalSigningBackend::from_uri("secretx:local-signing?algorithm=ed25519"),
430            Err(SecretError::InvalidUri(_))
431        ));
432    }
433
434    #[test]
435    fn from_uri_omitted_algorithm_auto_detects() {
436        // Without ?algorithm=, the backend reads the file and auto-detects.
437        // A missing file returns NotFound, not InvalidUri.
438        assert!(matches!(
439            LocalSigningBackend::from_uri("secretx:local-signing:/tmp/nonexistent-key.der"),
440            Err(SecretError::NotFound)
441        ));
442    }
443
444    #[test]
445    fn from_uri_unknown_algorithm() {
446        // Key file doesn't need to exist for algorithm rejection.
447        assert!(matches!(
448            LocalSigningBackend::from_uri("secretx:local-signing:/tmp/key.der?algorithm=elgamal"),
449            Err(SecretError::InvalidUri(_))
450        ));
451    }
452
453    #[test]
454    fn from_uri_path_traversal_rejected() {
455        assert!(matches!(
456            LocalSigningBackend::from_uri(
457                "secretx:local-signing:../../etc/shadow?algorithm=ed25519"
458            ),
459            Err(SecretError::InvalidUri(_))
460        ));
461    }
462
463    // A valid URI pointing to a non-existent file must return NotFound, not
464    // Backend, so callers using NotFound to trigger a fallback path see the
465    // right error variant.
466    #[test]
467    fn from_uri_nonexistent_file_returns_not_found() {
468        let result = LocalSigningBackend::from_uri(
469            "secretx:local-signing:/nonexistent/path/that/will/never/exist.der?algorithm=ed25519",
470        );
471        assert!(
472            matches!(result, Err(SecretError::NotFound)),
473            "missing key file must return NotFound"
474        );
475    }
476
477    // ── Ed25519 ───────────────────────────────────────────────────────────────
478
479    #[tokio::test]
480    async fn ed25519_loads_and_signs() {
481        let backend = load_or_skip!(ed25519_uri());
482        assert_eq!(
483            backend.algorithm().expect("algorithm"),
484            SigningAlgorithm::Ed25519
485        );
486
487        let message = b"hello, ed25519";
488        let sig_bytes = backend.sign(message).await.expect("sign failed");
489        assert_eq!(sig_bytes.len(), 64, "Ed25519 signature must be 64 bytes");
490
491        // Verify using the verifying key derived from the same signing key —
492        // independent path through the library's verification code.
493        let pub_der = backend
494            .public_key_der()
495            .await
496            .expect("public_key_der failed");
497        assert!(!pub_der.is_empty());
498
499        // Round-trip: decode the public key DER and verify the signature.
500        let vk = <ed25519_dalek::VerifyingKey as Ed25519DecodePublicKey>::from_public_key_der(&pub_der)
501            .expect("VerifyingKey from DER failed");
502        let sig = ed25519_dalek::Signature::from_bytes(
503            sig_bytes.as_slice().try_into().expect("sig length wrong"),
504        );
505        Ed25519Verifier::verify(&vk, message, &sig)
506            .expect("Ed25519 signature verification failed");
507    }
508
509    #[tokio::test]
510    async fn ed25519_different_messages_differ() {
511        let backend = load_or_skip!(ed25519_uri());
512        let s1 = backend.sign(b"message one").await.unwrap();
513        let s2 = backend.sign(b"message two").await.unwrap();
514        assert_ne!(s1, s2);
515    }
516
517    // ── P-256 ────────────────────────────────────────────────────────────────
518
519    #[tokio::test]
520    async fn p256_loads_and_signs() {
521        let backend = load_or_skip!(p256_uri());
522        assert_eq!(
523            backend.algorithm().expect("algorithm"),
524            SigningAlgorithm::EcdsaP256Sha256
525        );
526
527        let message = b"hello, p256";
528        let sig_bytes = backend.sign(message).await.expect("sign failed");
529        assert!(!sig_bytes.is_empty(), "P-256 signature must not be empty");
530
531        let pub_der = backend
532            .public_key_der()
533            .await
534            .expect("public_key_der failed");
535        assert!(!pub_der.is_empty());
536
537        // Verify using P-256 verifying key decoded from the DER.
538        let vk = <P256VerifyingKey as P256DecodePublicKey>::from_public_key_der(&pub_der)
539            .expect("P-256 VerifyingKey from DER failed");
540        let sig = P256Signature::from_bytes(sig_bytes.as_slice().into())
541            .expect("P-256 Signature decode failed");
542        P256Verifier::verify(&vk, message, &sig)
543            .expect("P-256 signature verification failed");
544    }
545
546    #[tokio::test]
547    async fn p256_different_messages_differ() {
548        let backend = load_or_skip!(p256_uri());
549        let s1 = backend.sign(b"message one").await.unwrap();
550        let s2 = backend.sign(b"message two").await.unwrap();
551        assert_ne!(s1, s2);
552    }
553
554    // ── RSA-PSS-2048 ─────────────────────────────────────────────────────────
555
556    #[tokio::test]
557    async fn rsa_pss_loads_and_signs() {
558        let backend = load_or_skip!(rsa_uri());
559        assert_eq!(
560            backend.algorithm().expect("algorithm"),
561            SigningAlgorithm::RsaPss2048Sha256
562        );
563
564        let message = b"hello, rsa-pss";
565        let sig_bytes = backend.sign(message).await.expect("sign failed");
566        assert_eq!(
567            sig_bytes.len(),
568            256,
569            "RSA-2048 PSS signature must be 256 bytes"
570        );
571
572        let pub_der = backend
573            .public_key_der()
574            .await
575            .expect("public_key_der failed");
576        assert!(!pub_der.is_empty());
577
578        // Verify using RSA-PSS verifying key decoded from the DER.
579        let pub_key = <rsa::RsaPublicKey as RsaDecodePublicKey>::from_public_key_der(&pub_der)
580            .expect("RSA public key from DER failed");
581        let vk = RsaPssVerifyingKey::<sha2::Sha256>::new(pub_key);
582        let sig = rsa::pss::Signature::try_from(sig_bytes.as_slice())
583            .expect("RSA-PSS Signature decode failed");
584        RsaVerifier::verify(&vk, message, &sig)
585            .expect("RSA-PSS signature verification failed");
586    }
587
588    #[tokio::test]
589    async fn rsa_pss_different_messages_differ() {
590        let backend = load_or_skip!(rsa_uri());
591        let s1 = backend.sign(b"message one").await.unwrap();
592        let s2 = backend.sign(b"message two").await.unwrap();
593        assert_ne!(s1, s2);
594    }
595
596    // RSA-PSS is randomized so two signatures of the same message differ.
597    #[tokio::test]
598    async fn rsa_pss_same_message_randomized() {
599        let backend = load_or_skip!(rsa_uri());
600        let s1 = backend.sign(b"same message").await.unwrap();
601        let s2 = backend.sign(b"same message").await.unwrap();
602        // PSS is probabilistic — signatures should differ with overwhelming probability.
603        assert_ne!(s1, s2, "RSA-PSS should produce randomized signatures");
604    }
605
606    // ── public_key_der is stable ──────────────────────────────────────────────
607
608    #[tokio::test]
609    async fn ed25519_public_key_der_stable() {
610        let b = load_or_skip!(ed25519_uri());
611        let d1 = b.public_key_der().await.unwrap();
612        let d2 = b.public_key_der().await.unwrap();
613        assert_eq!(d1, d2);
614    }
615}