Skip to main content

tsaur_core/
tls.rs

1//! Encrypted, peer-authenticated connections for the volume exchange: TLS 1.3 (rustls with the
2//! ring provider) between two T-saur instances that each hold a self-signed certificate. There is
3//! no certificate authority, account or server of ours: identities are **pinned locally** by the
4//! SHA-256 fingerprint of the certificate, which the user copies through a channel they trust
5//! (the same channel that carries the set id). A connection is accepted only when the remote
6//! certificate hashes to a pinned fingerprint; everything else in the certificate (names, dates,
7//! issuer) is ignored on purpose.
8//!
9//! Revocation is local too (`Revocations`): a text file of fingerprints that this side refuses
10//! even when they are pinned or allowed, read once at start-up and checked before the pin, so a
11//! lost key can be shut out without touching every allow list. Nothing on the wire can add or
12//! remove a revocation.
13//!
14//! Files: `<identity>` holds the PKCS#8 private key (PEM), `<identity>.crt` the certificate (PEM).
15//! `fingerprint()` is what a user shares; the private key never leaves the machine.
16
17use crate::error::{Error, Result};
18use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
19use rustls::crypto::{CryptoProvider, WebPkiSupportedAlgorithms};
20use rustls::server::danger::{ClientCertVerified, ClientCertVerifier};
21use rustls::{ClientConfig, DigitallySignedStruct, DistinguishedName, ServerConfig, SignatureScheme};
22use rustls_pki_types::pem::PemObject;
23use rustls_pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime};
24use sha2::{Digest, Sha256};
25use std::collections::HashSet;
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28
29/// Name presented in the handshake; it carries no meaning because identity is the fingerprint.
30pub const PEER_NAME: &str = "tsaur-peer";
31
32/// Largest revocation file accepted (a wrong path such as an archive fails fast).
33pub const MAX_REVOCATION_FILE: u64 = 1 << 20;
34
35pub struct Identity {
36    pub cert: CertificateDer<'static>,
37    pub key: PrivateKeyDer<'static>,
38    pub fingerprint: [u8; 32],
39}
40
41impl Clone for Identity {
42    fn clone(&self) -> Self {
43        Identity { cert: self.cert.clone(), key: self.key.clone_key(), fingerprint: self.fingerprint }
44    }
45}
46
47pub fn fingerprint(cert: &CertificateDer<'_>) -> [u8; 32] {
48    let mut h = Sha256::new();
49    h.update(cert.as_ref());
50    h.finalize().into()
51}
52
53pub fn cert_path(identity: &Path) -> PathBuf {
54    PathBuf::from(format!("{}.crt", identity.display()))
55}
56
57fn provider() -> Arc<CryptoProvider> {
58    Arc::new(rustls::crypto::ring::default_provider())
59}
60
61/// Generate a fresh ECDSA P-256 key and a self-signed certificate; write `<path>` (key) and
62/// `<path>.crt` (certificate) and return the identity.
63pub fn generate(path: &Path) -> Result<Identity> {
64    let key = rcgen::KeyPair::generate().map_err(|e| Error::Crypto(format!("key generation: {e}")))?;
65    let params = rcgen::CertificateParams::new(vec![PEER_NAME.to_string()]).map_err(|e| Error::Crypto(format!("certificate parameters: {e}")))?;
66    let cert = params.self_signed(&key).map_err(|e| Error::Crypto(format!("self-signed certificate: {e}")))?;
67    std::fs::write(path, key.serialize_pem())?;
68    std::fs::write(cert_path(path), cert.pem())?;
69    load(path)
70}
71
72/// Load an identity written by `generate`.
73pub fn load(path: &Path) -> Result<Identity> {
74    let key_pem = std::fs::read(path).map_err(|e| Error::Missing(format!("identity {}: {e}", path.display())))?;
75    let cert_pem = std::fs::read(cert_path(path)).map_err(|e| Error::Missing(format!("certificate {}: {e}", cert_path(path).display())))?;
76    let key = PrivateKeyDer::from_pem_slice(&key_pem).map_err(|e| Error::Crypto(format!("identity {}: {e}", path.display())))?;
77    let cert = CertificateDer::from_pem_slice(&cert_pem).map_err(|e| Error::Crypto(format!("certificate {}: {e}", cert_path(path).display())))?;
78    let fingerprint = fingerprint(&cert);
79    Ok(Identity { cert, key, fingerprint })
80}
81
82pub fn parse_fingerprint(hex_str: &str) -> Result<[u8; 32]> {
83    let v = hex::decode(hex_str.trim().replace(':', "")).map_err(|_| Error::Invalid("fingerprint must be 64 hex characters".into()))?;
84    let arr: [u8; 32] = v.as_slice().try_into().map_err(|_| Error::Invalid("fingerprint must be 32 bytes".into()))?;
85    Ok(arr)
86}
87
88/// Certificate fingerprints refused even when pinned or allowed (`docs/design/VOLUME-TRUST.md`
89/// §6.3). One fingerprint per line; `#` starts a comment, blank lines are ignored, colons and
90/// letter case are tolerated. Read once; there is no propagation and no expiry.
91#[derive(Clone, Debug, Default, PartialEq, Eq)]
92pub struct Revocations(HashSet<[u8; 32]>);
93
94impl Revocations {
95    /// Read and parse a revocation file (at most `MAX_REVOCATION_FILE` bytes).
96    pub fn load(path: &Path) -> Result<Revocations> {
97        let origin = path.display().to_string();
98        let len = std::fs::metadata(path).map_err(|e| Error::Missing(format!("revocation file {origin}: {e}")))?.len();
99        if len > MAX_REVOCATION_FILE {
100            return Err(Error::Limit(format!("revocation file {origin}: {len} bytes, more than the {MAX_REVOCATION_FILE}-byte limit; is this the right file?")));
101        }
102        let text = std::fs::read_to_string(path).map_err(|e| Error::Missing(format!("revocation file {origin}: {e}")))?;
103        Self::parse(&text, &origin)
104    }
105
106    /// Parse the text of a revocation file; `origin` names it in errors.
107    pub fn parse(text: &str, origin: &str) -> Result<Revocations> {
108        if text.len() as u64 > MAX_REVOCATION_FILE {
109            return Err(Error::Limit(format!("revocation file {origin}: more than the {MAX_REVOCATION_FILE}-byte limit")));
110        }
111        let mut set = HashSet::new();
112        for (i, raw) in text.lines().enumerate() {
113            let line = raw.split('#').next().unwrap_or("").trim();
114            if line.is_empty() {
115                continue;
116            }
117            let fp = parse_fingerprint(line).map_err(|_| Error::Invalid(format!("revocation file {origin}, line {}: fingerprint must be 64 hex characters", i + 1)))?;
118            set.insert(fp);
119        }
120        Ok(Revocations(set))
121    }
122
123    pub fn contains(&self, fp: &[u8; 32]) -> bool {
124        self.0.contains(fp)
125    }
126
127    pub fn len(&self) -> usize {
128        self.0.len()
129    }
130
131    pub fn is_empty(&self) -> bool {
132        self.0.is_empty()
133    }
134
135    pub fn iter(&self) -> impl Iterator<Item = &[u8; 32]> {
136        self.0.iter()
137    }
138}
139
140impl FromIterator<[u8; 32]> for Revocations {
141    fn from_iter<I: IntoIterator<Item = [u8; 32]>>(iter: I) -> Self {
142        Revocations(iter.into_iter().collect())
143    }
144}
145
146/// Accepts exactly the pinned server certificate, whatever it says about itself, unless its
147/// fingerprint is revoked.
148#[derive(Debug)]
149struct PinnedServer {
150    expected: [u8; 32],
151    revoked: Revocations,
152    algs: WebPkiSupportedAlgorithms,
153}
154
155impl ServerCertVerifier for PinnedServer {
156    fn verify_server_cert(
157        &self,
158        end_entity: &CertificateDer<'_>,
159        _intermediates: &[CertificateDer<'_>],
160        _server_name: &ServerName<'_>,
161        _ocsp: &[u8],
162        _now: UnixTime,
163    ) -> std::result::Result<ServerCertVerified, rustls::Error> {
164        let fp = fingerprint(end_entity);
165        if self.revoked.contains(&fp) {
166            Err(rustls::Error::General(format!("peer certificate fingerprint {} is revoked", hex::encode(fp))))
167        } else if fp == self.expected {
168            Ok(ServerCertVerified::assertion())
169        } else {
170            Err(rustls::Error::General(format!("peer certificate fingerprint {} does not match the pinned identity", hex::encode(fp))))
171        }
172    }
173
174    fn verify_tls12_signature(&self, message: &[u8], cert: &CertificateDer<'_>, dss: &DigitallySignedStruct) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
175        rustls::crypto::verify_tls12_signature(message, cert, dss, &self.algs)
176    }
177
178    fn verify_tls13_signature(&self, message: &[u8], cert: &CertificateDer<'_>, dss: &DigitallySignedStruct) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
179        rustls::crypto::verify_tls13_signature(message, cert, dss, &self.algs)
180    }
181
182    fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
183        self.algs.supported_schemes()
184    }
185}
186
187/// Accepts client certificates whose fingerprint is in the allow list (mutual TLS); a revoked
188/// fingerprint is refused first, even when listed.
189#[derive(Debug)]
190struct PinnedClients {
191    allowed: Vec<[u8; 32]>,
192    revoked: Revocations,
193    algs: WebPkiSupportedAlgorithms,
194}
195
196impl ClientCertVerifier for PinnedClients {
197    fn root_hint_subjects(&self) -> &[DistinguishedName] {
198        &[]
199    }
200
201    fn verify_client_cert(&self, end_entity: &CertificateDer<'_>, _intermediates: &[CertificateDer<'_>], _now: UnixTime) -> std::result::Result<ClientCertVerified, rustls::Error> {
202        let fp = fingerprint(end_entity);
203        if self.revoked.contains(&fp) {
204            Err(rustls::Error::General(format!("client certificate fingerprint {} is revoked", hex::encode(fp))))
205        } else if self.allowed.contains(&fp) {
206            Ok(ClientCertVerified::assertion())
207        } else {
208            Err(rustls::Error::General(format!("client certificate fingerprint {} is not in the allow list", hex::encode(fp))))
209        }
210    }
211
212    fn verify_tls12_signature(&self, message: &[u8], cert: &CertificateDer<'_>, dss: &DigitallySignedStruct) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
213        rustls::crypto::verify_tls12_signature(message, cert, dss, &self.algs)
214    }
215
216    fn verify_tls13_signature(&self, message: &[u8], cert: &CertificateDer<'_>, dss: &DigitallySignedStruct) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
217        rustls::crypto::verify_tls13_signature(message, cert, dss, &self.algs)
218    }
219
220    fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
221        self.algs.supported_schemes()
222    }
223}
224
225/// Server side: present `identity`; when `allowed` is non-empty require a client certificate
226/// whose fingerprint is listed (mutual authentication), otherwise accept anonymous clients
227/// (the connection is still encrypted and the server is still authenticated by the client).
228pub fn server_config(identity: &Identity, allowed: &[[u8; 32]]) -> Result<Arc<ServerConfig>> {
229    server_config_with(identity, allowed, &Revocations::default())
230}
231
232/// `server_config` with a revocation list: a revoked client fingerprint fails the handshake even
233/// when it is in `allowed`. Anonymous clients present no certificate, so revocations have no
234/// effect when `allowed` is empty.
235pub fn server_config_with(identity: &Identity, allowed: &[[u8; 32]], revoked: &Revocations) -> Result<Arc<ServerConfig>> {
236    let provider = provider();
237    let algs = provider.signature_verification_algorithms;
238    let builder = ServerConfig::builder_with_provider(provider).with_protocol_versions(&[&rustls::version::TLS13]).map_err(|e| Error::Crypto(format!("tls: {e}")))?;
239    let cert_chain = vec![identity.cert.clone()];
240    let config = if allowed.is_empty() {
241        builder.with_no_client_auth().with_single_cert(cert_chain, identity.key.clone_key())
242    } else {
243        builder.with_client_cert_verifier(Arc::new(PinnedClients { allowed: allowed.to_vec(), revoked: revoked.clone(), algs })).with_single_cert(cert_chain, identity.key.clone_key())
244    }
245    .map_err(|e| Error::Crypto(format!("tls server configuration: {e}")))?;
246    Ok(Arc::new(config))
247}
248
249/// Client side: accept only the server whose certificate fingerprint is `peer`; present
250/// `identity` when the server asks for a client certificate.
251pub fn client_config(identity: Option<&Identity>, peer: [u8; 32]) -> Result<Arc<ClientConfig>> {
252    client_config_with(identity, peer, &Revocations::default())
253}
254
255/// `client_config` with a revocation list: a revoked server fingerprint fails the handshake even
256/// when it is the pinned `peer`, before any request is sent.
257pub fn client_config_with(identity: Option<&Identity>, peer: [u8; 32], revoked: &Revocations) -> Result<Arc<ClientConfig>> {
258    let provider = provider();
259    let algs = provider.signature_verification_algorithms;
260    let builder = ClientConfig::builder_with_provider(provider)
261        .with_safe_default_protocol_versions()
262        .map_err(|e| Error::Crypto(format!("tls: {e}")))?
263        .dangerous()
264        .with_custom_certificate_verifier(Arc::new(PinnedServer { expected: peer, revoked: revoked.clone(), algs }));
265    let config = match identity {
266        Some(id) => builder.with_client_auth_cert(vec![id.cert.clone()], id.key.clone_key()).map_err(|e| Error::Crypto(format!("tls client configuration: {e}")))?,
267        None => builder.with_no_client_auth(),
268    };
269    Ok(Arc::new(config))
270}
271
272pub fn server_name() -> ServerName<'static> {
273    ServerName::try_from(PEER_NAME.to_string()).expect("constant peer name")
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn revocation_file_parses_comments_blank_lines_and_colons() {
282        let a = "3f".repeat(32);
283        let b: String = (0..32).map(|_| "7B:").collect::<String>().trim_end_matches(':').to_string();
284        let text = format!("# tsaur revocations\n\n{a}   # laptop key lost\r\n  {b}\n\n# trailing comment only\n");
285        let r = Revocations::parse(&text, "test").unwrap();
286        assert_eq!(r.len(), 2);
287        assert!(r.contains(&[0x3f; 32]) && r.contains(&[0x7b; 32]));
288        // duplicates are harmless, an empty file is empty
289        assert_eq!(Revocations::parse(&format!("{a}\n{a}\n"), "t").unwrap().len(), 1);
290        assert!(Revocations::parse("", "t").unwrap().is_empty());
291        assert!(Revocations::parse("# only comments\n\n", "t").unwrap().is_empty());
292        // a bad line names its number
293        let err = Revocations::parse("# ok\n\nnot-a-fingerprint\n", "revoked.txt").unwrap_err().to_string();
294        assert!(err.contains("revoked.txt, line 3"), "{err}");
295        let err = Revocations::parse(&"ab".repeat(31), "t").unwrap_err().to_string();
296        assert!(err.contains("line 1"), "{err}");
297        // more than the file limit is refused before parsing
298        let big = format!("{a}\n").repeat((MAX_REVOCATION_FILE as usize / 65) + 2);
299        assert!(matches!(Revocations::parse(&big, "t"), Err(Error::Limit(_))));
300    }
301}