dolang_rpc/auth.rs
1//! Optional shared secret authentication.
2//!
3//! Supplying [`AuthKey`] to [`Builder::key`](crate::Builder::key) performs mutual authentication
4//! during session negotiation.
5//!
6//! This is provided mainly for use with one-off Unix socket or Windows named pipe connections where
7//! controlling access to the endpoint or consistently discerning peer identity may be difficult,
8//! e.g. when crossing container boundaries with unknown or misconfigured identity mappings.
9//!
10//! **This does not provide message integrity or privacy**; the protocol remains unencrypted and
11//! unsigned, so it must be used over a private channel such as a Unix socket, or tunneled over a
12//! protocol that provides privacy and integrity, like SSH or TLS.
13//!
14//! Authentication is a simple derived key exchange with no nonce, so **it is not
15//! replay-resistant**. It is intended for single-use keys minted per session and exchanged
16//! beforehand over a secure side channel. It must carry sufficient entropy on its own.
17//!
18//! Each side advertises a digest derived from the key with a role-specific BLAKE3 key-derivation
19//! context, and checks the digest derived from the *other* role. Because the digests are one-way,
20//! an impostor that connects first and harvests the server's advertisement cannot derive the
21//! client's, and an impostor that binds the socket first cannot produce the server's. Both digests
22//! ride the existing symmetric negotiation exchange, so authentication costs no additional round
23//! trips.
24
25use std::fmt;
26
27use crate::Error;
28
29/// Minimum accepted length, in bytes, of a pre-shared authentication key.
30///
31/// Derivation happily turns a one-byte key into a respectable-looking 32-byte
32/// digest, so the floor is enforced rather than left to callers.
33pub const MIN_KEY_LEN: usize = 16;
34
35// Hardcoded, globally unique, and never built dynamically, per BLAKE3's
36// guidance for key-derivation contexts. The role suffix is what keeps a
37// harvested advertisement from being replayed back in the other direction, so
38// the two must never be collapsed into one context.
39const CLIENT_CONTEXT: &str = "dolang-rpc 2026-08-13 session auth client";
40const SERVER_CONTEXT: &str = "dolang-rpc 2026-08-13 session auth server";
41
42/// A pre-shared key, reduced to the pair of digests negotiation exchanges.
43///
44/// Constructing one derives both digests and discards the key material, so the
45/// secret itself is not retained for the life of the session.
46#[derive(Clone, Copy)]
47pub struct AuthKey {
48 client: blake3::Hash,
49 server: blake3::Hash,
50}
51
52impl AuthKey {
53 /// Derives the client and server digests from `key`.
54 ///
55 /// # Errors
56 ///
57 /// Returns [`Error::Auth`] if `key` is shorter than [`MIN_KEY_LEN`].
58 pub fn new(key: &[u8]) -> Result<Self, Error> {
59 if key.len() < MIN_KEY_LEN {
60 return Err(Error::Auth(format!(
61 "authentication key must be at least {MIN_KEY_LEN} bytes"
62 )));
63 }
64 Ok(Self {
65 client: derive(CLIENT_CONTEXT, key),
66 server: derive(SERVER_CONTEXT, key),
67 })
68 }
69
70 /// Returns the digests to send and expect when acting as the client.
71 pub(crate) fn as_client(&self) -> Auth {
72 Auth {
73 send: self.client,
74 expect: self.server,
75 }
76 }
77
78 /// Returns the digests to send and expect when acting as the server.
79 pub(crate) fn as_server(&self) -> Auth {
80 Auth {
81 send: self.server,
82 expect: self.client,
83 }
84 }
85}
86
87// A derived digest authenticates its side as effectively as the key does, so
88// printing one is equivalent to printing the secret.
89impl fmt::Debug for AuthKey {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 f.write_str("AuthKey(<redacted>)")
92 }
93}
94
95fn derive(context: &str, key: &[u8]) -> blake3::Hash {
96 // Going through the hasher rather than `blake3::derive_key` yields a
97 // `Hash`, whose `PartialEq` is constant-time; the bare `[u8; 32]` that the
98 // convenience function returns compares in variable time.
99 let mut hasher = blake3::Hasher::new_derive_key(context);
100 hasher.update(key);
101 hasher.finalize()
102}
103
104/// One side's view of an [`AuthKey`]: what to advertise, and what to require.
105#[derive(Clone, Copy)]
106pub(crate) struct Auth {
107 pub(crate) send: blake3::Hash,
108 pub(crate) expect: blake3::Hash,
109}
110
111impl Auth {
112 /// Returns the digest to place in the outgoing handshake.
113 pub(crate) fn advertise(&self) -> [u8; 32] {
114 *self.send.as_bytes()
115 }
116}
117
118/// Checks a peer's advertised digest against local configuration.
119///
120/// Every combination other than "both keyed and matching" or "neither keyed" is
121/// rejected, so a configuration mistake fails closed rather than silently
122/// dropping authentication. Note that a keyed peer talking to an unkeyed one is
123/// refused from both ends independently: the keyed side finds nothing to check,
124/// and the unkeyed side refuses to ignore a proof it cannot evaluate.
125pub(crate) fn verify(local: Option<Auth>, peer: Option<[u8; 32]>) -> Result<(), Error> {
126 match (local, peer) {
127 (None, None) => Ok(()),
128 (Some(local), Some(peer)) => {
129 // `blake3::Hash` compares in constant time; `[u8; 32]` does not.
130 if local.expect == blake3::Hash::from(peer) {
131 Ok(())
132 } else {
133 // Deliberately says nothing about the expected or received
134 // digest: either one authenticates its side.
135 Err(Error::Auth("peer failed authentication".into()))
136 }
137 }
138 (Some(_), None) => Err(Error::Auth(
139 "peer did not authenticate but a key is configured".into(),
140 )),
141 (None, Some(_)) => Err(Error::Auth(
142 "peer authenticated but no key is configured".into(),
143 )),
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 const KEY: &[u8] = b"0123456789abcdef";
152
153 #[test]
154 fn keys_shorter_than_the_minimum_are_rejected() {
155 let short = &KEY[..MIN_KEY_LEN - 1];
156 let error = AuthKey::new(short).unwrap_err();
157 assert!(matches!(error, Error::Auth(ref msg) if msg.contains("at least")));
158 assert!(AuthKey::new(KEY).is_ok());
159 }
160
161 #[test]
162 fn client_and_server_digests_differ_for_the_same_key() {
163 let key = AuthKey::new(KEY).unwrap();
164 assert_ne!(key.as_client().advertise(), key.as_server().advertise());
165 // Each side expects what the other sends.
166 assert_eq!(
167 key.as_client().advertise(),
168 *key.as_server().expect.as_bytes()
169 );
170 assert_eq!(
171 key.as_server().advertise(),
172 *key.as_client().expect.as_bytes()
173 );
174 }
175
176 #[test]
177 fn verify_accepts_matching_peers_in_both_directions() {
178 let key = AuthKey::new(KEY).unwrap();
179 let client = key.as_client();
180 let server = key.as_server();
181 verify(Some(client), Some(server.advertise())).unwrap();
182 verify(Some(server), Some(client.advertise())).unwrap();
183 }
184
185 #[test]
186 fn verify_rejects_a_replayed_advertisement() {
187 // A peer that harvested the server's advertisement cannot use it to
188 // authenticate as the client, which is what keeps "connect first" from
189 // being worth anything.
190 let key = AuthKey::new(KEY).unwrap();
191 let server = key.as_server();
192 let error = verify(Some(server), Some(server.advertise())).unwrap_err();
193 assert!(matches!(error, Error::Auth(_)));
194 }
195
196 #[test]
197 fn verify_rejects_a_different_key() {
198 let key = AuthKey::new(KEY).unwrap();
199 let other = AuthKey::new(b"fedcba9876543210").unwrap();
200 let error = verify(Some(key.as_client()), Some(other.as_server().advertise())).unwrap_err();
201 assert!(matches!(error, Error::Auth(_)));
202 }
203
204 #[test]
205 fn verify_rejects_mismatched_configuration_in_both_directions() {
206 let key = AuthKey::new(KEY).unwrap();
207 let error = verify(Some(key.as_client()), None).unwrap_err();
208 assert!(matches!(error, Error::Auth(ref msg) if msg.contains("did not authenticate")));
209 let error = verify(None, Some(key.as_client().advertise())).unwrap_err();
210 assert!(matches!(error, Error::Auth(ref msg) if msg.contains("no key is configured")));
211 verify(None, None).unwrap();
212 }
213
214 #[test]
215 fn debug_does_not_expose_derived_digests() {
216 let key = AuthKey::new(KEY).unwrap();
217 assert_eq!(format!("{key:?}"), "AuthKey(<redacted>)");
218 }
219}