Skip to main content

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}