Skip to main content

harn_vm/secrets/
mod.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::sync::Arc;
4use std::time::Duration;
5
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use zeroize::{Zeroize, Zeroizing};
9
10mod env;
11mod keyring;
12
13pub use env::EnvSecretProvider;
14pub use keyring::KeyringSecretProvider;
15
16pub const DEFAULT_SECRET_PROVIDER_CHAIN: &str = "env,keyring";
17pub const SECRET_PROVIDER_CHAIN_ENV: &str = "HARN_SECRET_PROVIDERS";
18
19#[derive(Clone, Debug, Default, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
20pub enum SecretVersion {
21    #[default]
22    Latest,
23    Exact(u64),
24}
25
26#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
27pub struct SecretId {
28    pub namespace: String,
29    pub name: String,
30    #[serde(default)]
31    pub version: SecretVersion,
32}
33
34impl SecretId {
35    pub fn new(namespace: impl Into<String>, name: impl Into<String>) -> Self {
36        Self {
37            namespace: namespace.into(),
38            name: name.into(),
39            version: SecretVersion::Latest,
40        }
41    }
42
43    pub fn with_version(mut self, version: SecretVersion) -> Self {
44        self.version = version;
45        self
46    }
47}
48
49impl fmt::Display for SecretId {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        if self.namespace.is_empty() {
52            write!(f, "{}", self.name)?;
53        } else {
54            write!(f, "{}/{}", self.namespace, self.name)?;
55        }
56        match self.version {
57            SecretVersion::Latest => Ok(()),
58            SecretVersion::Exact(version) => write!(f, "@{version}"),
59        }
60    }
61}
62
63#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
64pub struct SecretMeta {
65    pub id: SecretId,
66    pub provider: String,
67}
68
69#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
70pub struct RotationHandle {
71    pub provider: String,
72    pub id: SecretId,
73    pub from_version: Option<u64>,
74    pub to_version: Option<u64>,
75}
76
77#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum SecretScope {
80    Tenant { id: Option<String> },
81    Workspace { id: String },
82    System,
83    Custom { kind: String, id: Option<String> },
84}
85
86impl Default for SecretScope {
87    fn default() -> Self {
88        Self::Tenant { id: None }
89    }
90}
91
92impl SecretScope {
93    pub fn tenant(id: Option<String>) -> Self {
94        Self::Tenant { id }
95    }
96
97    pub fn workspace(id: impl Into<String>) -> Self {
98        Self::Workspace { id: id.into() }
99    }
100
101    pub fn system() -> Self {
102        Self::System
103    }
104
105    pub fn custom(kind: impl Into<String>, id: Option<String>) -> Self {
106        Self::Custom {
107            kind: kind.into(),
108            id,
109        }
110    }
111
112    pub fn namespace(&self) -> String {
113        match self {
114            Self::Tenant { id: Some(id) } if !id.is_empty() => format!("harn.tenant.{id}"),
115            Self::Tenant { .. } => "harn.tenant".to_string(),
116            Self::Workspace { id } => format!("harn.workspace.{id}"),
117            Self::System => "harn.system".to_string(),
118            Self::Custom { kind, id: Some(id) } if !id.is_empty() => {
119                format!("harn.{kind}.{id}")
120            }
121            Self::Custom { kind, .. } => format!("harn.{kind}"),
122        }
123    }
124
125    pub fn kind(&self) -> &str {
126        match self {
127            Self::Tenant { .. } => "tenant",
128            Self::Workspace { .. } => "workspace",
129            Self::System => "system",
130            Self::Custom { kind, .. } => kind.as_str(),
131        }
132    }
133
134    pub fn id(&self) -> Option<&str> {
135        match self {
136            Self::Tenant { id } | Self::Custom { id, .. } => id.as_deref(),
137            Self::Workspace { id } => Some(id.as_str()),
138            Self::System => None,
139        }
140    }
141}
142
143#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
144pub struct SecretWriteOptions {
145    pub ttl: Option<Duration>,
146}
147
148#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
149pub struct SecretRotationOptions {
150    pub grace: Option<Duration>,
151    pub ttl: Option<Duration>,
152}
153
154#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
155pub struct SecretAuditContext {
156    pub request_id: Option<String>,
157    pub actor_subject: Option<String>,
158    pub actor_kind: Option<String>,
159}
160
161#[derive(Debug)]
162pub struct SecretReadRequest {
163    pub id: SecretId,
164    pub scope: SecretScope,
165    pub audit: SecretAuditContext,
166}
167
168#[derive(Debug)]
169pub struct SecretWriteRequest {
170    pub id: SecretId,
171    pub scope: SecretScope,
172    pub value: SecretBytes,
173    pub options: SecretWriteOptions,
174    pub audit: SecretAuditContext,
175}
176
177#[derive(Debug)]
178pub struct SecretRotateRequest {
179    pub id: SecretId,
180    pub scope: SecretScope,
181    pub value: SecretBytes,
182    pub options: SecretRotationOptions,
183    pub audit: SecretAuditContext,
184}
185
186#[derive(Debug)]
187pub struct SecretLeaseRequest {
188    pub id: SecretId,
189    pub scope: SecretScope,
190    pub duration: Duration,
191    pub audit: SecretAuditContext,
192}
193
194#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
195pub struct SecretWriteReceipt {
196    pub provider: String,
197    pub id: SecretId,
198    pub scope: SecretScope,
199    pub version: Option<u64>,
200    pub expires_at_unix_ms: Option<i64>,
201}
202
203#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
204pub struct SecretRotationReceipt {
205    pub provider: String,
206    pub id: SecretId,
207    pub scope: SecretScope,
208    pub from_version: Option<u64>,
209    pub to_version: Option<u64>,
210    pub grace_until_unix_ms: Option<i64>,
211    pub expires_at_unix_ms: Option<i64>,
212}
213
214#[derive(Debug)]
215pub struct SecretLeaseGrant {
216    pub provider: String,
217    pub id: SecretId,
218    pub scope: SecretScope,
219    pub lease_id: String,
220    pub value: SecretBytes,
221    pub expires_at_unix_ms: i64,
222}
223
224#[derive(Clone, Debug, Eq, PartialEq)]
225pub enum SecretError {
226    NotFound {
227        provider: String,
228        id: SecretId,
229    },
230    Unsupported {
231        provider: String,
232        operation: &'static str,
233    },
234    Backend {
235        provider: String,
236        message: String,
237    },
238    InvalidConfig(String),
239    InvalidInput(String),
240    NoProviders {
241        namespace: String,
242    },
243    All(Vec<SecretError>),
244}
245
246impl fmt::Display for SecretError {
247    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248        match self {
249            Self::NotFound { provider, id } => {
250                write!(f, "{provider}: secret '{id}' not found")
251            }
252            Self::Unsupported {
253                provider,
254                operation,
255            } => write!(f, "{provider}: operation '{operation}' is unsupported"),
256            Self::Backend { provider, message } => write!(f, "{provider}: {message}"),
257            Self::InvalidConfig(message) => write!(f, "{message}"),
258            Self::InvalidInput(message) => write!(f, "{message}"),
259            Self::NoProviders { namespace } => {
260                write!(
261                    f,
262                    "no secret providers configured for namespace '{namespace}'"
263                )
264            }
265            Self::All(errors) => {
266                let rendered = errors
267                    .iter()
268                    .map(ToString::to_string)
269                    .collect::<Vec<_>>()
270                    .join("; ");
271                write!(f, "all secret providers failed: {rendered}")
272            }
273        }
274    }
275}
276
277impl std::error::Error for SecretError {}
278
279#[derive(Default)]
280struct SecretBuffer {
281    bytes: Vec<u8>,
282    #[cfg(test)]
283    drop_probe: Option<std::sync::Arc<std::sync::Mutex<Option<Vec<u8>>>>>,
284}
285
286impl SecretBuffer {
287    fn new(bytes: Vec<u8>) -> Self {
288        Self {
289            bytes,
290            #[cfg(test)]
291            drop_probe: None,
292        }
293    }
294
295    fn as_slice(&self) -> &[u8] {
296        &self.bytes
297    }
298
299    #[cfg(test)]
300    fn attach_drop_probe(&mut self, probe: std::sync::Arc<std::sync::Mutex<Option<Vec<u8>>>>) {
301        self.drop_probe = Some(probe);
302    }
303}
304
305impl std::ops::Deref for SecretBuffer {
306    type Target = [u8];
307
308    fn deref(&self) -> &Self::Target {
309        self.as_slice()
310    }
311}
312
313impl Zeroize for SecretBuffer {
314    fn zeroize(&mut self) {
315        self.bytes.zeroize();
316    }
317}
318
319impl Drop for SecretBuffer {
320    fn drop(&mut self) {
321        #[cfg(test)]
322        if let Some(probe) = &self.drop_probe {
323            *probe.lock().expect("drop probe poisoned") = Some(self.bytes.clone());
324        }
325    }
326}
327
328pub struct SecretBytes(Zeroizing<SecretBuffer>);
329
330impl SecretBytes {
331    pub fn new(bytes: Vec<u8>) -> Self {
332        Self(Zeroizing::new(SecretBuffer::new(bytes)))
333    }
334
335    pub fn len(&self) -> usize {
336        self.0.as_slice().len()
337    }
338
339    pub fn is_empty(&self) -> bool {
340        self.0.as_slice().is_empty()
341    }
342
343    pub fn with_exposed<R>(&self, f: impl FnOnce(&[u8]) -> R) -> R {
344        f(self.0.as_slice())
345    }
346
347    pub fn reborrow(&self) -> Self {
348        self.with_exposed(|bytes| Self::new(bytes.to_vec()))
349    }
350
351    #[cfg(test)]
352    pub(crate) fn attach_drop_probe(
353        &mut self,
354        probe: std::sync::Arc<std::sync::Mutex<Option<Vec<u8>>>>,
355    ) {
356        self.0.attach_drop_probe(probe);
357    }
358}
359
360impl fmt::Debug for SecretBytes {
361    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
362        write!(f, "SecretBytes {{ redacted: {} bytes }}", self.len())
363    }
364}
365
366impl Serialize for SecretBytes {
367    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
368    where
369        S: serde::Serializer,
370    {
371        serializer.serialize_str(&format!("<redacted:{} bytes>", self.len()))
372    }
373}
374
375impl From<Vec<u8>> for SecretBytes {
376    fn from(value: Vec<u8>) -> Self {
377        Self::new(value)
378    }
379}
380
381impl From<String> for SecretBytes {
382    fn from(value: String) -> Self {
383        Self::new(value.into_bytes())
384    }
385}
386
387impl From<&str> for SecretBytes {
388    fn from(value: &str) -> Self {
389        Self::new(value.as_bytes().to_vec())
390    }
391}
392
393impl From<&[u8]> for SecretBytes {
394    fn from(value: &[u8]) -> Self {
395        Self::new(value.to_vec())
396    }
397}
398
399#[async_trait]
400pub trait SecretProvider: Send + Sync {
401    async fn get(&self, id: &SecretId) -> Result<SecretBytes, SecretError>;
402    async fn put(&self, id: &SecretId, value: SecretBytes) -> Result<(), SecretError>;
403    async fn rotate(&self, id: &SecretId) -> Result<RotationHandle, SecretError>;
404    async fn list(&self, prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError>;
405
406    async fn read_scoped(&self, request: SecretReadRequest) -> Result<SecretBytes, SecretError> {
407        self.get(&request.id).await
408    }
409
410    async fn write_scoped(
411        &self,
412        request: SecretWriteRequest,
413    ) -> Result<SecretWriteReceipt, SecretError> {
414        if request.options.ttl.is_some() {
415            return Err(SecretError::Unsupported {
416                provider: self.namespace().to_string(),
417                operation: "write_ttl",
418            });
419        }
420        self.put(&request.id, request.value).await?;
421        Ok(SecretWriteReceipt {
422            provider: self.namespace().to_string(),
423            id: request.id,
424            scope: request.scope,
425            version: None,
426            expires_at_unix_ms: None,
427        })
428    }
429
430    async fn rotate_scoped(
431        &self,
432        request: SecretRotateRequest,
433    ) -> Result<SecretRotationReceipt, SecretError> {
434        let _ = request;
435        Err(SecretError::Unsupported {
436            provider: self.namespace().to_string(),
437            operation: "rotate_to",
438        })
439    }
440
441    async fn lease_scoped(
442        &self,
443        request: SecretLeaseRequest,
444    ) -> Result<SecretLeaseGrant, SecretError> {
445        let _ = request;
446        Err(SecretError::Unsupported {
447            provider: self.namespace().to_string(),
448            operation: "lease",
449        })
450    }
451
452    fn namespace(&self) -> &str;
453    fn supports_versions(&self) -> bool;
454}
455
456pub struct ChainSecretProvider {
457    namespace: String,
458    providers: Vec<Arc<dyn SecretProvider>>,
459}
460
461impl ChainSecretProvider {
462    pub fn new(namespace: impl Into<String>, providers: Vec<Arc<dyn SecretProvider>>) -> Self {
463        Self {
464            namespace: namespace.into(),
465            providers,
466        }
467    }
468
469    pub fn providers(&self) -> &[Arc<dyn SecretProvider>] {
470        &self.providers
471    }
472}
473
474#[async_trait]
475impl SecretProvider for ChainSecretProvider {
476    async fn get(&self, id: &SecretId) -> Result<SecretBytes, SecretError> {
477        if self.providers.is_empty() {
478            return Err(SecretError::NoProviders {
479                namespace: self.namespace.clone(),
480            });
481        }
482
483        let mut errors = Vec::new();
484        for provider in &self.providers {
485            match provider.get(id).await {
486                Ok(secret) => return Ok(secret),
487                Err(error) => errors.push(error),
488            }
489        }
490
491        Err(SecretError::All(errors))
492    }
493
494    async fn put(&self, id: &SecretId, value: SecretBytes) -> Result<(), SecretError> {
495        if self.providers.is_empty() {
496            return Err(SecretError::NoProviders {
497                namespace: self.namespace.clone(),
498            });
499        }
500
501        let mut last_value = Some(value);
502        let mut errors = Vec::new();
503        for (index, provider) in self.providers.iter().enumerate() {
504            let attempt_value = if index + 1 == self.providers.len() {
505                last_value
506                    .take()
507                    .expect("final secret write attempt missing value")
508            } else {
509                last_value
510                    .as_ref()
511                    .expect("intermediate secret write attempt missing value")
512                    .reborrow()
513            };
514            match provider.put(id, attempt_value).await {
515                Ok(()) => return Ok(()),
516                Err(error) => errors.push(error),
517            }
518        }
519
520        Err(SecretError::All(errors))
521    }
522
523    async fn rotate(&self, id: &SecretId) -> Result<RotationHandle, SecretError> {
524        if self.providers.is_empty() {
525            return Err(SecretError::NoProviders {
526                namespace: self.namespace.clone(),
527            });
528        }
529
530        let mut errors = Vec::new();
531        for provider in &self.providers {
532            match provider.rotate(id).await {
533                Ok(handle) => return Ok(handle),
534                Err(error) => errors.push(error),
535            }
536        }
537
538        Err(SecretError::All(errors))
539    }
540
541    async fn list(&self, prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError> {
542        if self.providers.is_empty() {
543            return Err(SecretError::NoProviders {
544                namespace: self.namespace.clone(),
545            });
546        }
547
548        let mut errors = Vec::new();
549        let mut merged = BTreeMap::<SecretId, SecretMeta>::new();
550        for provider in &self.providers {
551            match provider.list(prefix).await {
552                Ok(items) => {
553                    for item in items {
554                        merged.entry(item.id.clone()).or_insert(item);
555                    }
556                }
557                Err(error) => errors.push(error),
558            }
559        }
560
561        if merged.is_empty() && !errors.is_empty() {
562            return Err(SecretError::All(errors));
563        }
564
565        Ok(merged.into_values().collect())
566    }
567
568    fn namespace(&self) -> &str {
569        &self.namespace
570    }
571
572    fn supports_versions(&self) -> bool {
573        self.providers
574            .iter()
575            .any(|provider| provider.supports_versions())
576    }
577}
578
579pub fn configured_default_chain(
580    namespace: impl Into<String>,
581) -> Result<ChainSecretProvider, SecretError> {
582    let namespace = namespace.into();
583    let configured = std::env::var(SECRET_PROVIDER_CHAIN_ENV)
584        .unwrap_or_else(|_| DEFAULT_SECRET_PROVIDER_CHAIN.to_string());
585    let mut providers: Vec<Arc<dyn SecretProvider>> = Vec::new();
586
587    for raw_name in configured.split(',') {
588        let provider_name = raw_name.trim();
589        if provider_name.is_empty() {
590            continue;
591        }
592        match provider_name {
593            "env" => providers.push(Arc::new(EnvSecretProvider::new(namespace.clone()))),
594            "keyring" => providers.push(Arc::new(KeyringSecretProvider::new(namespace.clone()))),
595            other => {
596                return Err(SecretError::InvalidConfig(format!(
597                    "unsupported secret provider '{other}' in {SECRET_PROVIDER_CHAIN_ENV}; expected a comma-separated list of env,keyring"
598                )))
599            }
600        }
601    }
602
603    Ok(ChainSecretProvider::new(namespace, providers))
604}
605
606pub(crate) fn emit_secret_access_event(provider: &str, id: &SecretId) {
607    #[derive(Serialize)]
608    struct SecretAccessEvent<'a> {
609        topic: &'a str,
610        provider: &'a str,
611        id: &'a SecretId,
612        caller_span_id: Option<u64>,
613        mutation_session_id: Option<String>,
614        timestamp: String,
615    }
616
617    let event = SecretAccessEvent {
618        topic: "audit.secret_access",
619        provider,
620        id,
621        caller_span_id: crate::tracing::current_span_id(),
622        mutation_session_id: crate::orchestration::current_mutation_session()
623            .map(|session| session.session_id),
624        timestamp: crate::orchestration::now_rfc3339(),
625    };
626    let metadata = serde_json::to_value(event)
627        .ok()
628        .and_then(|value| value.as_object().cloned())
629        .map(|object| object.into_iter().collect::<BTreeMap<_, _>>())
630        .unwrap_or_default();
631    crate::events::log_info_meta("secret.audit", "secret accessed", metadata);
632}
633
634#[cfg(test)]
635mod tests {
636    use std::sync::{Arc, Mutex, Once};
637
638    use async_trait::async_trait;
639
640    use super::*;
641
642    fn install_mock_keyring() {
643        static INIT: Once = Once::new();
644        INIT.call_once(|| {
645            ::keyring::set_default_credential_builder(::keyring::mock::default_credential_builder());
646        });
647    }
648
649    struct FakeProvider {
650        namespace: String,
651        result: Mutex<Vec<Result<SecretBytes, SecretError>>>,
652    }
653
654    impl FakeProvider {
655        fn new(
656            namespace: impl Into<String>,
657            result: Vec<Result<SecretBytes, SecretError>>,
658        ) -> Self {
659            Self {
660                namespace: namespace.into(),
661                result: Mutex::new(result),
662            }
663        }
664    }
665
666    #[async_trait]
667    impl SecretProvider for FakeProvider {
668        async fn get(&self, _id: &SecretId) -> Result<SecretBytes, SecretError> {
669            self.result
670                .lock()
671                .expect("fake provider poisoned")
672                .remove(0)
673        }
674
675        async fn put(&self, _id: &SecretId, _value: SecretBytes) -> Result<(), SecretError> {
676            Err(SecretError::Unsupported {
677                provider: self.namespace.clone(),
678                operation: "put",
679            })
680        }
681
682        async fn rotate(&self, _id: &SecretId) -> Result<RotationHandle, SecretError> {
683            Err(SecretError::Unsupported {
684                provider: self.namespace.clone(),
685                operation: "rotate",
686            })
687        }
688
689        async fn list(&self, _prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError> {
690            Err(SecretError::Unsupported {
691                provider: self.namespace.clone(),
692                operation: "list",
693            })
694        }
695
696        fn namespace(&self) -> &str {
697            &self.namespace
698        }
699
700        fn supports_versions(&self) -> bool {
701            false
702        }
703    }
704
705    #[test]
706    fn secret_bytes_debug_is_redacted() {
707        let secret = SecretBytes::from("abcd");
708        assert_eq!(format!("{secret:?}"), "SecretBytes { redacted: 4 bytes }");
709    }
710
711    #[test]
712    fn secret_bytes_zeroes_on_drop() {
713        let probe = Arc::new(Mutex::new(None));
714        let mut secret = SecretBytes::from("super-secret");
715        secret.attach_drop_probe(probe.clone());
716        drop(secret);
717
718        let dropped = probe
719            .lock()
720            .expect("drop probe poisoned")
721            .clone()
722            .expect("probe should capture bytes");
723        assert!(dropped.iter().all(|byte| *byte == 0));
724    }
725
726    #[tokio::test]
727    async fn chain_secret_provider_falls_through_to_next_hit() {
728        let id = SecretId::new("harn.test", "api-key");
729        let first = Arc::new(FakeProvider::new(
730            "first",
731            vec![Err(SecretError::NotFound {
732                provider: "first".to_string(),
733                id: id.clone(),
734            })],
735        ));
736        let second = Arc::new(FakeProvider::new(
737            "second",
738            vec![Ok(SecretBytes::from("value"))],
739        ));
740        let chain = ChainSecretProvider::new("harn/test", vec![first, second]);
741
742        let secret = chain.get(&id).await.expect("chain should resolve");
743        let exposed = secret.with_exposed(|bytes| bytes.to_vec());
744        assert_eq!(exposed, b"value");
745    }
746
747    #[tokio::test]
748    async fn chain_secret_provider_returns_all_errors_when_everything_fails() {
749        let id = SecretId::new("harn.test", "missing");
750        let first = Arc::new(FakeProvider::new(
751            "first",
752            vec![Err(SecretError::NotFound {
753                provider: "first".to_string(),
754                id: id.clone(),
755            })],
756        ));
757        let second = Arc::new(FakeProvider::new(
758            "second",
759            vec![Err(SecretError::Backend {
760                provider: "second".to_string(),
761                message: "boom".to_string(),
762            })],
763        ));
764        let chain = ChainSecretProvider::new("harn/test", vec![first, second]);
765
766        let error = chain.get(&id).await.expect_err("chain should fail");
767        match error {
768            SecretError::All(errors) => {
769                assert_eq!(errors.len(), 2);
770                assert!(matches!(errors[0], SecretError::NotFound { .. }));
771                assert!(matches!(errors[1], SecretError::Backend { .. }));
772            }
773            other => panic!("expected aggregated errors, got {other:?}"),
774        }
775    }
776
777    #[tokio::test]
778    async fn keyring_provider_round_trips_and_zeroes_on_drop() {
779        install_mock_keyring();
780
781        let provider = KeyringSecretProvider::new("harn.test");
782        let id = SecretId::new("", format!("mock-{}", uuid::Uuid::now_v7()));
783        provider
784            .put(&id, SecretBytes::from("round-trip-secret"))
785            .await
786            .expect("mock keyring write should succeed");
787
788        let probe = Arc::new(Mutex::new(None));
789        let mut secret = provider
790            .get(&id)
791            .await
792            .expect("mock keyring read should succeed");
793        assert_eq!(
794            secret.with_exposed(|bytes| bytes.to_vec()),
795            b"round-trip-secret"
796        );
797        secret.attach_drop_probe(probe.clone());
798        drop(secret);
799
800        let dropped = probe
801            .lock()
802            .expect("drop probe poisoned")
803            .clone()
804            .expect("probe should capture bytes");
805        assert!(dropped.iter().all(|byte| *byte == 0));
806
807        provider
808            .delete(&id)
809            .await
810            .expect("mock keyring delete should succeed");
811    }
812}