Skip to main content

riffdb_types/
capability.rs

1//! Foundational capability-token digest values.
2
3use std::fmt;
4
5use crate::{DIGEST_SCHEME_V1, DigestKeyId};
6
7/// Maximum audiences retained by one capability.
8pub const MAX_CAPABILITY_AUDIENCES: usize = 8;
9/// Maximum requested capability lifetime in seconds.
10pub const MAX_CAPABILITY_LIFETIME_SECONDS: u32 = 2_592_000;
11
12/// Closed revocation reason registry.
13#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
14pub enum RevocationReasonCodeV1 {
15    /// Explicit operator request.
16    Requested,
17    /// Capability was replaced.
18    Replaced,
19    /// Suspected credential compromise.
20    SuspectedCompromise,
21    /// Current policy changed.
22    PolicyChange,
23}
24
25impl RevocationReasonCodeV1 {
26    /// Returns the immutable v1 tag.
27    #[must_use]
28    pub const fn tag(self) -> u8 {
29        match self {
30            Self::Requested => 0x01,
31            Self::Replaced => 0x02,
32            Self::SuspectedCompromise => 0x03,
33            Self::PolicyChange => 0x04,
34        }
35    }
36
37    /// Decodes a stable v1 revocation-reason tag.
38    #[must_use]
39    pub const fn from_tag(tag: u8) -> Option<Self> {
40        match tag {
41            0x01 => Some(Self::Requested),
42            0x02 => Some(Self::Replaced),
43            0x03 => Some(Self::SuspectedCompromise),
44            0x04 => Some(Self::PolicyChange),
45            _ => None,
46        }
47    }
48}
49
50/// A versioned capability-token lookup digest.
51///
52/// The digest bytes are not bearer credentials, but their formatting is still
53/// redacted. Construction records only already-computed v1 HMAC bytes; the
54/// domain-separated calculation is provided by
55/// [`hash_capability_token`](crate::hash_capability_token).
56#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
57pub struct CapabilityTokenDigest {
58    key_id: DigestKeyId,
59    bytes: [u8; 32],
60}
61
62impl CapabilityTokenDigest {
63    /// Creates a v1 capability-token digest from its key ID and HMAC bytes.
64    #[must_use]
65    pub const fn from_hmac_bytes(key_id: DigestKeyId, bytes: [u8; 32]) -> Self {
66        Self { key_id, bytes }
67    }
68
69    /// Returns the immutable digest-scheme version.
70    #[must_use]
71    pub const fn scheme(self) -> u8 {
72        DIGEST_SCHEME_V1
73    }
74
75    /// Returns the non-secret immutable digest-key identifier.
76    #[must_use]
77    pub const fn key_id(self) -> DigestKeyId {
78        self.key_id
79    }
80
81    /// Explicitly borrows the 32 HMAC bytes for a reviewed storage lookup.
82    #[must_use]
83    pub const fn as_bytes(&self) -> &[u8; 32] {
84        &self.bytes
85    }
86}
87
88impl fmt::Debug for CapabilityTokenDigest {
89    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
90        formatter
91            .debug_struct("CapabilityTokenDigest")
92            .field("scheme", &DIGEST_SCHEME_V1)
93            .field("key_id", &self.key_id)
94            .field("bytes", &"[REDACTED]")
95            .finish()
96    }
97}
98
99impl fmt::Display for CapabilityTokenDigest {
100    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
101        formatter.write_str("capability token digest [REDACTED]")
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    fn digest_key_id(value: u32) -> DigestKeyId {
110        DigestKeyId::try_from(value).expect("nonzero digest key ID")
111    }
112
113    #[test]
114    fn formatting_redacts_digest_bytes() {
115        let digest = CapabilityTokenDigest::from_hmac_bytes(digest_key_id(7), [0xab; 32]);
116        let debug = format!("{digest:?}");
117        let display = digest.to_string();
118
119        assert!(debug.contains("[REDACTED]"));
120        assert!(display.contains("[REDACTED]"));
121        assert!(!debug.contains("abababab"));
122        assert!(!display.contains("abababab"));
123        assert_eq!(digest.scheme(), DIGEST_SCHEME_V1);
124        assert_eq!(digest.key_id(), digest_key_id(7));
125        assert_eq!(digest.as_bytes(), &[0xab; 32]);
126    }
127
128    #[test]
129    fn capability_hard_limits_have_exact_v1_values() {
130        assert_eq!(MAX_CAPABILITY_AUDIENCES, 8);
131        assert_eq!(MAX_CAPABILITY_LIFETIME_SECONDS, 2_592_000);
132    }
133
134    #[test]
135    fn revocation_reason_registry_has_exact_v1_tags() {
136        let registry = [
137            (RevocationReasonCodeV1::Requested, 0x01),
138            (RevocationReasonCodeV1::Replaced, 0x02),
139            (RevocationReasonCodeV1::SuspectedCompromise, 0x03),
140            (RevocationReasonCodeV1::PolicyChange, 0x04),
141        ];
142
143        for (reason, tag) in registry {
144            assert_eq!(reason.tag(), tag);
145            assert_eq!(RevocationReasonCodeV1::from_tag(tag), Some(reason));
146        }
147        assert_eq!(RevocationReasonCodeV1::from_tag(0), None);
148        assert_eq!(RevocationReasonCodeV1::from_tag(5), None);
149        assert_eq!(RevocationReasonCodeV1::from_tag(u8::MAX), None);
150    }
151}