Skip to main content

appcore_security/
secret.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: secret.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/05/31 13:38:42 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/06/04 11:51:26 by dnettoRaw
8//      ###########      S: 0.6.0
9// =============================================================================
10
11//! Secret reference contracts for local secure material handling.
12
13use crate::token::SecurityResult;
14use crate::SecurityError;
15use std::collections::HashMap;
16use std::fmt;
17use std::time::{SystemTime, UNIX_EPOCH};
18use zeroize::Zeroize;
19
20pub use crate::secret_file::FileSecretResolver;
21
22/// Opaque reference in the security-store address space.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct SecuritySecretRef(
25    /// Provider-owned opaque reference value.
26    pub String,
27);
28
29/// Contract for secret retrieval by opaque reference.
30pub trait SecretStore {
31    /// Stores secret bytes and returns an opaque reference.
32    fn put(&mut self, data: Vec<u8>) -> SecurityResult<SecuritySecretRef>;
33    /// Resolves secret bytes from an opaque reference.
34    fn get(&self, reference: &SecuritySecretRef) -> SecurityResult<Vec<u8>>;
35}
36
37/// Secret bytes that redact their debug representation and clear memory on drop.
38#[derive(Clone, PartialEq, Eq)]
39pub struct SecretBytes(Vec<u8>);
40
41impl SecretBytes {
42    /// Takes ownership of secret bytes.
43    pub fn new(value: Vec<u8>) -> Self {
44        Self(value)
45    }
46
47    /// Exposes secret bytes to an explicit trusted caller.
48    pub fn as_bytes(&self) -> &[u8] {
49        &self.0
50    }
51}
52
53impl fmt::Debug for SecretBytes {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        f.write_str("SecretBytes(REDACTED)")
56    }
57}
58
59impl Drop for SecretBytes {
60    fn drop(&mut self) {
61        self.0.zeroize();
62    }
63}
64
65/// Resolves opaque security-store references.
66pub trait SecretResolver: Send + Sync {
67    /// Resolves one secret or returns a controlled availability error.
68    fn resolve(&self, reference: &SecuritySecretRef) -> SecurityResult<SecretBytes>;
69}
70
71/// Resolves security references as environment variable names.
72#[derive(Debug, Clone, Copy, Default)]
73pub struct EnvSecretResolver;
74
75impl SecretResolver for EnvSecretResolver {
76    fn resolve(&self, reference: &SecuritySecretRef) -> SecurityResult<SecretBytes> {
77        let value = std::env::var(&reference.0).map_err(|_| SecurityError::SecretUnavailable)?;
78        Ok(SecretBytes::new(value.into_bytes()))
79    }
80}
81
82/// Deterministic resolver backed by an immutable in-memory map.
83#[derive(Debug, Clone)]
84pub struct StaticSecretResolver {
85    secrets: HashMap<String, SecretBytes>,
86}
87
88impl StaticSecretResolver {
89    /// Creates a resolver from opaque reference values to secret bytes.
90    pub fn new(secrets: HashMap<String, SecretBytes>) -> Self {
91        Self { secrets }
92    }
93}
94
95impl SecretResolver for StaticSecretResolver {
96    fn resolve(&self, reference: &SecuritySecretRef) -> SecurityResult<SecretBytes> {
97        self.secrets
98            .get(&reference.0)
99            .cloned()
100            .ok_or(SecurityError::SecretUnavailable)
101    }
102}
103
104/// Key identity and secret material used for one peer.
105#[derive(Clone, PartialEq, Eq)]
106pub struct PeerCredential {
107    /// Rotation-aware key identity.
108    pub key_id: String,
109    /// Authentication secret bytes.
110    pub secret: SecretBytes,
111}
112
113impl fmt::Debug for PeerCredential {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        f.debug_struct("PeerCredential")
116            .field("key_id", &self.key_id)
117            .field("secret", &"REDACTED")
118            .finish()
119    }
120}
121
122/// Resolves credentials for direct peer authentication.
123pub trait PeerCredentialProvider: Send + Sync {
124    /// Returns the credential assigned to one peer Core.
125    fn credential_for_peer(&self, peer_core_id: &str) -> SecurityResult<PeerCredential>;
126}
127
128/// Deterministic peer credential provider backed by an immutable map.
129#[derive(Debug, Clone)]
130pub struct StaticPeerCredentialProvider {
131    credentials: HashMap<String, PeerCredential>,
132}
133
134impl StaticPeerCredentialProvider {
135    /// Creates a provider from peer Core IDs to credentials.
136    pub fn new(credentials: HashMap<String, PeerCredential>) -> Self {
137        Self { credentials }
138    }
139}
140
141impl PeerCredentialProvider for StaticPeerCredentialProvider {
142    fn credential_for_peer(&self, peer_core_id: &str) -> SecurityResult<PeerCredential> {
143        self.credentials
144            .get(peer_core_id)
145            .cloned()
146            .ok_or(SecurityError::SecretUnavailable)
147    }
148}
149
150/// Rotation lifecycle of secret material.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum SecuritySecretStatus {
153    /// Secret may issue and validate new credentials.
154    Active,
155    /// Secret may validate existing credentials but should not issue new ones.
156    Deprecated,
157    /// Secret must not be accepted.
158    Revoked,
159}
160
161/// Non-secret metadata required for rotation and expiry policy.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct SecuritySecretMetadata {
164    /// Stable key identity.
165    pub key_id: String,
166    /// Creation timestamp in Unix milliseconds.
167    pub created_at_ms: u64,
168    /// Optional expiry timestamp in Unix milliseconds.
169    pub expires_at_ms: Option<u64>,
170    /// Rotation lifecycle state.
171    pub status: SecuritySecretStatus,
172}
173
174/// Secret bytes paired with rotation metadata.
175#[derive(Clone, PartialEq, Eq)]
176pub struct SecuritySecretMaterial {
177    /// Secret bytes, zeroized on drop.
178    pub secret: Vec<u8>,
179    /// Non-secret rotation metadata.
180    pub metadata: SecuritySecretMetadata,
181}
182
183impl fmt::Debug for SecuritySecretMaterial {
184    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185        formatter
186            .debug_struct("SecuritySecretMaterial")
187            .field("secret", &"REDACTED")
188            .field("metadata", &self.metadata)
189            .finish()
190    }
191}
192
193impl Drop for SecuritySecretMaterial {
194    fn drop(&mut self) {
195        self.secret.zeroize();
196    }
197}
198
199/// Controlled secret material parsing or generation failure.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub enum SecretFormatError {
202    /// Structured material is malformed.
203    InvalidFormat(&'static str),
204    /// Secret bytes fail minimum validation.
205    InvalidSecret,
206    /// Operating-system random source is unavailable.
207    RandomUnavailable,
208}
209
210impl fmt::Display for SecretFormatError {
211    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212        match self {
213            SecretFormatError::InvalidFormat(msg) => write!(f, "{msg}"),
214            SecretFormatError::InvalidSecret => write!(f, "invalid secret"),
215            SecretFormatError::RandomUnavailable => write!(f, "OS random source unavailable"),
216        }
217    }
218}
219
220impl SecuritySecretStatus {
221    /// Returns the stable serialized status label.
222    pub fn as_str(&self) -> &'static str {
223        match self {
224            SecuritySecretStatus::Active => "active",
225            SecuritySecretStatus::Deprecated => "deprecated",
226            SecuritySecretStatus::Revoked => "revoked",
227        }
228    }
229}
230
231impl SecuritySecretMaterial {
232    /// Reports whether the configured expiry has passed.
233    pub fn is_expired(&self, now_ms: u64) -> bool {
234        self.metadata
235            .expires_at_ms
236            .map(|exp| exp <= now_ms)
237            .unwrap_or(false)
238    }
239}
240
241/// Parses structured secret material.
242pub fn parse_secret_material(input: &[u8]) -> Result<SecuritySecretMaterial, SecretFormatError> {
243    let text = std::str::from_utf8(input)
244        .map_err(|_| SecretFormatError::InvalidFormat("NO MORE SUPPORTED PLEASE UPDATE"))?;
245    if !text.contains("secret=") {
246        return Err(SecretFormatError::InvalidFormat(
247            "NO MORE SUPPORTED PLEASE UPDATE",
248        ));
249    }
250    parse_structured_secret(text)
251}
252
253/// Formats structured secret material for deployment-owned storage.
254pub fn format_secret_material(material: &SecuritySecretMaterial) -> String {
255    let expires = material
256        .metadata
257        .expires_at_ms
258        .map(|value| value.to_string())
259        .unwrap_or_else(|| "none".to_string());
260    format!(
261        "key_id={}\ncreated_at_ms={}\nexpires_at_ms={}\nstatus={}\nsecret={}\n",
262        material.metadata.key_id,
263        material.metadata.created_at_ms,
264        expires,
265        material.metadata.status.as_str(),
266        encode_hex(&material.secret)
267    )
268}
269
270/// Generates 256-bit rotation material using the operating-system random source.
271pub fn new_rotated_secret(
272    expires_at_ms: Option<u64>,
273) -> Result<SecuritySecretMaterial, SecretFormatError> {
274    let now_ms = SystemTime::now()
275        .duration_since(UNIX_EPOCH)
276        .map(|d| d.as_millis() as u64)
277        .unwrap_or(0);
278    let mut secret = vec![0u8; 32];
279    getrandom::getrandom(&mut secret).map_err(|_| SecretFormatError::RandomUnavailable)?;
280    let key_id = format!(
281        "k-{now_ms}-{:02x}{:02x}{:02x}{:02x}",
282        secret[0], secret[1], secret[2], secret[3]
283    );
284    Ok(SecuritySecretMaterial {
285        secret,
286        metadata: SecuritySecretMetadata {
287            key_id,
288            created_at_ms: now_ms,
289            expires_at_ms,
290            status: SecuritySecretStatus::Active,
291        },
292    })
293}
294
295fn parse_structured_secret(text: &str) -> Result<SecuritySecretMaterial, SecretFormatError> {
296    let mut key_id = None::<String>;
297    let mut created_at_ms = None::<u64>;
298    let mut expires_at_ms = None::<Option<u64>>;
299    let mut status = None::<SecuritySecretStatus>;
300    let mut secret = None::<Vec<u8>>;
301    for line in text.lines() {
302        let line = line.trim();
303        if line.is_empty() || line.starts_with('#') {
304            continue;
305        }
306        let Some((k, v)) = line.split_once('=') else {
307            continue;
308        };
309        let value = v.trim();
310        match k.trim() {
311            "key_id" => key_id = Some(value.to_string()),
312            "created_at_ms" => {
313                created_at_ms = Some(
314                    value
315                        .parse::<u64>()
316                        .map_err(|_| SecretFormatError::InvalidFormat("invalid created_at_ms"))?,
317                )
318            }
319            "expires_at_ms" => {
320                if value == "none" {
321                    expires_at_ms = Some(None);
322                } else {
323                    expires_at_ms =
324                        Some(Some(value.parse::<u64>().map_err(|_| {
325                            SecretFormatError::InvalidFormat("invalid expires_at_ms")
326                        })?));
327                }
328            }
329            "status" => {
330                status = Some(match value {
331                    "active" => SecuritySecretStatus::Active,
332                    "deprecated" => SecuritySecretStatus::Deprecated,
333                    "revoked" => SecuritySecretStatus::Revoked,
334                    _ => return Err(SecretFormatError::InvalidFormat("invalid status")),
335                })
336            }
337            "secret" => {
338                let bytes = if let Some(value) = value.strip_prefix("hex:") {
339                    decode_hex(value).ok_or(SecretFormatError::InvalidSecret)?
340                } else if looks_like_hex(value) {
341                    decode_hex(value).unwrap_or_else(|| value.as_bytes().to_vec())
342                } else {
343                    value.as_bytes().to_vec()
344                };
345                secret = Some(bytes);
346            }
347            _ => {}
348        }
349    }
350    let key_id = key_id.ok_or(SecretFormatError::InvalidFormat("missing key_id"))?;
351    let created_at_ms =
352        created_at_ms.ok_or(SecretFormatError::InvalidFormat("missing created_at_ms"))?;
353    let expires_at_ms = expires_at_ms.unwrap_or(None);
354    let status = status.ok_or(SecretFormatError::InvalidFormat("missing status"))?;
355    let secret = secret.ok_or(SecretFormatError::InvalidFormat("missing secret"))?;
356    if secret.len() < 16 {
357        return Err(SecretFormatError::InvalidSecret);
358    }
359    Ok(SecuritySecretMaterial {
360        secret,
361        metadata: SecuritySecretMetadata {
362            key_id,
363            created_at_ms,
364            expires_at_ms,
365            status,
366        },
367    })
368}
369
370fn looks_like_hex(value: &str) -> bool {
371    !value.is_empty()
372        && value.len().is_multiple_of(2)
373        && value.bytes().all(|b| b.is_ascii_hexdigit())
374}
375
376fn encode_hex(bytes: &[u8]) -> String {
377    const HEX: &[u8; 16] = b"0123456789abcdef";
378    let mut out = String::with_capacity(bytes.len() * 2);
379    for byte in bytes {
380        out.push(HEX[(byte >> 4) as usize] as char);
381        out.push(HEX[(byte & 0x0f) as usize] as char);
382    }
383    out
384}
385
386fn decode_hex(input: &str) -> Option<Vec<u8>> {
387    if input.is_empty() || !input.len().is_multiple_of(2) {
388        return None;
389    }
390    let mut output = Vec::with_capacity(input.len() / 2);
391    let bytes = input.as_bytes();
392    let mut index = 0usize;
393    while index < bytes.len() {
394        let hi = hex_value(bytes[index])?;
395        let lo = hex_value(bytes[index + 1])?;
396        output.push((hi << 4) | lo);
397        index += 2;
398    }
399    Some(output)
400}
401
402fn hex_value(byte: u8) -> Option<u8> {
403    match byte {
404        b'0'..=b'9' => Some(byte - b'0'),
405        b'a'..=b'f' => Some(10 + byte - b'a'),
406        b'A'..=b'F' => Some(10 + byte - b'A'),
407        _ => None,
408    }
409}
410
411#[cfg(test)]
412#[path = "secret_tests.rs"]
413mod tests;