Skip to main content

cairn_mod/
signing_key.rs

1//! Signing-key newtype (§5.1).
2//!
3//! The wrapper enforces the §5.1 compile-time guarantees:
4//! - Key bytes are never printed via `Debug` (custom redacting impl).
5//! - Key material is never serialized (no `Serialize` / `Deserialize` impl).
6//! - Memory holding key bytes is cleared on drop (`Zeroize` + `ZeroizeOnDrop`).
7//!
8//! The runtime-safe load path (file-only input, mode `0600` check,
9//! owner check, explicit env-var rejection) is enforced at
10//! [`SigningKey::load_from_file`] — the entry point used by
11//! `cairn serve`.
12
13use std::fmt;
14use std::path::{Path, PathBuf};
15
16use thiserror::Error;
17use zeroize::{Zeroize, ZeroizeOnDrop};
18
19use crate::credential_file::{self, CredentialFileError};
20
21/// Env var whose presence `cairn serve` refuses at startup (§5.1).
22/// Codifying this rejection here prevents an "ergonomics" PR later
23/// adding a `CAIRN_SIGNING_KEY=<hex>` escape hatch — any such change
24/// breaks this constant's test.
25pub const SIGNING_KEY_ENV_REJECTED: &str = "CAIRN_SIGNING_KEY";
26
27/// Error surface for the file-based key loader.
28#[derive(Debug, Error)]
29pub enum KeyLoadError {
30    /// Underlying credential-file failure (permissions, ownership,
31    /// env-override rejection, I/O).
32    #[error("signing key file: {0}")]
33    CredentialFile(#[from] CredentialFileError),
34    /// File exists but isn't valid hex.
35    #[error(
36        "signing key file {path} is not valid hex (expected 64 hex chars, optional trailing newline)"
37    )]
38    NotHex {
39        /// Path whose contents failed hex decoding.
40        path: PathBuf,
41    },
42    /// File decoded as hex but isn't exactly 32 bytes.
43    #[error("signing key file {path} decodes to {got} bytes; expected 32")]
44    WrongLength {
45        /// Path whose decoded length didn't match.
46        path: PathBuf,
47        /// Actual decoded length.
48        got: usize,
49    },
50}
51
52/// 32-byte k256 (secp256k1) private signing-key material.
53///
54/// Deliberately omitted traits:
55/// - `Serialize` / `Deserialize` — key bytes never leave the process as data.
56/// - `Clone` — `ZeroizeOnDrop` assumes a single owner.
57/// - `PartialEq` / `Eq` — equality would require constant-time comparison
58///   (via the `subtle` crate); add it there if a caller actually needs it.
59/// - `Display` — same redaction rationale as `Debug`.
60#[derive(Zeroize, ZeroizeOnDrop)]
61pub struct SigningKey([u8; 32]);
62
63impl SigningKey {
64    /// Wrap raw key bytes.
65    ///
66    /// Callers must source the bytes safely (§5.1: file-only, mode `0600`,
67    /// owner check, env-var rejection).
68    pub fn from_bytes(bytes: [u8; 32]) -> Self {
69        Self(bytes)
70    }
71
72    /// Borrow the raw 32-byte scalar. Intentionally `pub(crate)`: the
73    /// only in-tree consumer is `crate::signing`, which needs the bytes to
74    /// construct a `K256Keypair`. The name flags the invariant breach
75    /// (§5.1 "never serialized, never printed") so it isn't reached for
76    /// casually.
77    pub(crate) fn expose_secret(&self) -> &[u8; 32] {
78        &self.0
79    }
80
81    /// Load a signing key from `path`. Enforces §5.1 end-to-end:
82    ///
83    /// 1. Refuse if [`SIGNING_KEY_ENV_REJECTED`] is set — key material
84    ///    is file-only.
85    /// 2. File mode exactly `0o600`, owned by current effective UID
86    ///    (shared with the session-file invariant).
87    /// 3. Contents parsed as hex (64 chars, optional trailing
88    ///    newline/whitespace).
89    /// 4. Decoded length must be 32 bytes.
90    ///
91    /// Intermediate buffers holding the decoded bytes are explicitly
92    /// zeroized before drop so key material has one owner (the
93    /// returned `SigningKey`) by the time this function returns.
94    pub fn load_from_file(path: &Path) -> Result<Self, KeyLoadError> {
95        credential_file::reject_env_override(SIGNING_KEY_ENV_REJECTED)?;
96        credential_file::check_mode_and_owner(path)?;
97
98        // Read as owned bytes so we can zeroize on drop rather than
99        // leaving the file content in an unzeroized `String`.
100        let mut raw = std::fs::read(path)
101            .map_err(|e| KeyLoadError::CredentialFile(CredentialFileError::Io(e)))?;
102        // Trim surrounding ASCII whitespace (newlines, spaces) without
103        // allocating.
104        let start = raw
105            .iter()
106            .position(|b| !b.is_ascii_whitespace())
107            .unwrap_or(raw.len());
108        let end = raw
109            .iter()
110            .rposition(|b| !b.is_ascii_whitespace())
111            .map(|i| i + 1)
112            .unwrap_or(0);
113        let trimmed = &raw[start..end];
114
115        let mut decoded = match hex::decode(trimmed) {
116            Ok(v) => v,
117            Err(_) => {
118                raw.zeroize();
119                return Err(KeyLoadError::NotHex {
120                    path: path.to_path_buf(),
121                });
122            }
123        };
124        raw.zeroize();
125
126        if decoded.len() != 32 {
127            let got = decoded.len();
128            decoded.zeroize();
129            return Err(KeyLoadError::WrongLength {
130                path: path.to_path_buf(),
131                got,
132            });
133        }
134
135        let mut bytes = [0u8; 32];
136        bytes.copy_from_slice(&decoded);
137        decoded.zeroize();
138
139        // `[u8; 32]` is Copy — `from_bytes(bytes)` receives its own
140        // copy and the local `bytes` still holds the scalar.
141        // Zeroize the local before returning so SigningKey is the
142        // sole remaining owner of the material.
143        let key = SigningKey::from_bytes(bytes);
144        bytes.zeroize();
145        Ok(key)
146    }
147}
148
149impl fmt::Debug for SigningKey {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        f.write_str("SigningKey(<redacted>)")
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::SigningKey;
158
159    #[test]
160    fn debug_redacts_bytes() {
161        let key = SigningKey::from_bytes([0xAB; 32]);
162        let dbg = format!("{key:?}");
163        assert_eq!(dbg, "SigningKey(<redacted>)");
164        assert!(!dbg.contains("AB"));
165        assert!(!dbg.contains("ab"));
166        assert!(!dbg.contains("171")); // 0xAB in decimal
167    }
168}