toolkit_security/shared_secret.rs
1//! Shared-secret platform-plane authenticator (dev / single-node profiles).
2//!
3//! [`SharedSecretInternalAuthenticator`] is a dependency-light concrete
4//! [`InternalAuthenticator`] that validates an inbound
5//! `X-ToolKit-Internal-Token` by comparing it, in constant time, against a
6//! single pre-shared secret. On success it resolves the caller to
7//! [`PlatformIdentity::Shared`] with a configured label.
8//!
9//! It exists so the platform plane can be exercised **end-to-end without
10//! Kubernetes** (no `TokenReview` API, no projected service-account volume) —
11//! useful for local demos, single-node deployments, and tests. It is **not** a
12//! substitute for the K8s `TokenReview` validator in a real multi-tenant
13//! cluster: a single shared secret has none of `TokenReview`'s per-workload
14//! identity, rotation, or revocation properties.
15//!
16//! ```rust
17//! use secrecy::SecretString;
18//! use toolkit_security::{InternalAuthenticator, SharedSecretInternalAuthenticator};
19//!
20//! # async fn demo() {
21//! let auth = SharedSecretInternalAuthenticator::try_new(
22//! SecretString::from("dev-internal-token"),
23//! "toolkit-host".to_owned(),
24//! )
25//! .expect("a non-empty secret");
26//! assert!(auth.authenticate("dev-internal-token").await.is_ok());
27//! assert!(auth.authenticate("wrong").await.is_err());
28//! # }
29//! ```
30
31use secrecy::{ExposeSecret, SecretString};
32
33use crate::internal_auth::{InternalAuthNError, InternalAuthenticator, PlatformIdentity};
34
35/// A platform-plane authenticator backed by a single pre-shared secret.
36///
37/// See the [module docs](self) for when this is (and is not) appropriate.
38#[derive(Clone)]
39pub struct SharedSecretInternalAuthenticator {
40 secret: SecretString,
41 peer_name: String,
42}
43
44impl std::fmt::Debug for SharedSecretInternalAuthenticator {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 // Never render the secret.
47 f.debug_struct("SharedSecretInternalAuthenticator")
48 .field("peer_name", &self.peer_name)
49 .finish_non_exhaustive()
50 }
51}
52
53/// The placeholder written in place of a secret when a config is serialized.
54///
55/// Rejected as a secret: a config dumped by `--print-config` would otherwise
56/// round-trip into a working one whose platform credential is this literal —
57/// a value published in the repository.
58pub const REDACTED_PLACEHOLDER: &str = "<redacted>";
59
60/// Why a shared secret was refused.
61#[derive(Debug, thiserror::Error, PartialEq, Eq)]
62pub enum InvalidSharedSecret {
63 /// The secret was empty, which would authenticate an empty token.
64 #[error("shared secret must not be empty")]
65 Empty,
66 /// The secret is the redaction placeholder, i.e. a dumped config was fed
67 /// back in as a real one.
68 #[error(
69 "shared secret is the literal `{REDACTED_PLACEHOLDER}` placeholder, which a serialized \
70 config writes in place of the real secret"
71 )]
72 RedactedPlaceholder,
73}
74
75impl SharedSecretInternalAuthenticator {
76 /// Build an authenticator that accepts exactly `secret` and resolves valid
77 /// callers to [`PlatformIdentity::Shared`] with the label `peer_name`.
78 ///
79 /// # Errors
80 ///
81 /// Returns [`InvalidSharedSecret`] if the secret is empty or is the
82 /// [`REDACTED_PLACEHOLDER`]. An empty secret is the dangerous one: the
83 /// comparison in [`InternalAuthenticator::authenticate`] is over bytes, and
84 /// an empty secret matches an empty token — so anyone sending the internal
85 /// header with no value would authenticate as `peer_name`.
86 pub fn try_new(secret: SecretString, peer_name: String) -> Result<Self, InvalidSharedSecret> {
87 match secret.expose_secret() {
88 "" => Err(InvalidSharedSecret::Empty),
89 REDACTED_PLACEHOLDER => Err(InvalidSharedSecret::RedactedPlaceholder),
90 _ => Ok(Self { secret, peer_name }),
91 }
92 }
93}
94
95impl InternalAuthenticator for SharedSecretInternalAuthenticator {
96 async fn authenticate(&self, token: &str) -> Result<PlatformIdentity, InternalAuthNError> {
97 if constant_time_eq(token.as_bytes(), self.secret.expose_secret().as_bytes()) {
98 Ok(PlatformIdentity::Shared {
99 name: self.peer_name.clone(),
100 })
101 } else {
102 // A rejected platform-plane credential left no trace at all, so a
103 // peer configured with the wrong secret looked identical to one
104 // that was never configured. The peer name is the configured label,
105 // not anything the caller supplied, and the token never appears.
106 tracing::warn!(
107 peer_name = %self.peer_name,
108 "platform-plane authentication rejected: shared secret did not match"
109 );
110 Err(InternalAuthNError::InvalidToken)
111 }
112 }
113}
114
115/// Compare two byte slices without short-circuiting on the first differing
116/// byte, so validation time does not leak the position of a mismatch.
117///
118/// The length comparison is not itself constant-time; a pre-shared secret's
119/// length is not sensitive here.
120fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
121 if a.len() != b.len() {
122 return false;
123 }
124 let mut diff = 0u8;
125 for (x, y) in a.iter().zip(b.iter()) {
126 diff |= x ^ y;
127 }
128 diff == 0
129}
130
131#[cfg(test)]
132#[cfg_attr(coverage_nightly, coverage(off))]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn an_empty_secret_is_refused() {
138 // The comparison is over bytes, so an empty secret matches an empty
139 // token: anyone sending the internal header with no value would
140 // authenticate. Refusing at construction is the only place to catch it.
141 assert_eq!(
142 SharedSecretInternalAuthenticator::try_new(SecretString::from(""), "peer".to_owned())
143 .unwrap_err(),
144 InvalidSharedSecret::Empty
145 );
146 }
147
148 #[test]
149 fn the_redaction_placeholder_is_refused() {
150 // A config dumped by `--print-config` writes this in place of the
151 // secret and deserializes cleanly, so without this the platform plane
152 // would come up accepting a literal published in the repository.
153 assert_eq!(
154 SharedSecretInternalAuthenticator::try_new(
155 SecretString::from(REDACTED_PLACEHOLDER),
156 "peer".to_owned()
157 )
158 .unwrap_err(),
159 InvalidSharedSecret::RedactedPlaceholder
160 );
161 }
162
163 #[tokio::test]
164 async fn an_empty_token_is_rejected_by_a_real_secret() {
165 let rejected = auth().authenticate("").await;
166 assert!(matches!(rejected, Err(InternalAuthNError::InvalidToken)));
167 }
168
169 fn auth() -> SharedSecretInternalAuthenticator {
170 SharedSecretInternalAuthenticator::try_new(SecretString::from("s3cr3t"), "peer".to_owned())
171 .expect("a non-empty secret")
172 }
173
174 #[tokio::test]
175 async fn accepts_matching_secret_and_resolves_shared_identity() {
176 let identity = auth().authenticate("s3cr3t").await.expect("valid secret");
177 assert_eq!(
178 identity,
179 PlatformIdentity::Shared {
180 name: "peer".to_owned()
181 }
182 );
183 assert_eq!(identity.peer_name(), "peer");
184 }
185
186 #[tokio::test]
187 async fn rejects_wrong_secret() {
188 let err = auth().authenticate("nope").await.unwrap_err();
189 assert!(matches!(err, InternalAuthNError::InvalidToken));
190 }
191
192 #[tokio::test]
193 async fn rejects_empty_and_prefix_tokens() {
194 assert!(auth().authenticate("").await.is_err());
195 assert!(auth().authenticate("s3cr3").await.is_err());
196 assert!(auth().authenticate("s3cr3tt").await.is_err());
197 }
198
199 #[test]
200 fn constant_time_eq_matches_std_eq() {
201 assert!(constant_time_eq(b"abc", b"abc"));
202 assert!(!constant_time_eq(b"abc", b"abd"));
203 assert!(!constant_time_eq(b"abc", b"ab"));
204 assert!(constant_time_eq(b"", b""));
205 }
206
207 #[test]
208 fn debug_never_leaks_secret() {
209 let rendered = format!("{:?}", auth());
210 assert!(rendered.contains("peer"));
211 assert!(!rendered.contains("s3cr3t"));
212 }
213}