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}