Skip to main content

seyd_transport/
cert.rs

1// Copyright 2026 Anton Gravestam
2// SPDX-License-Identifier: Apache-2.0
3//! Self-signed certificates for `serverCertificateHashes`.
4//!
5//! Chrome accepts a self-signed certificate over WebTransport only if it is
6//! ECDSA (P-256 here), valid for at most 14 days, and — for IP-addressed
7//! servers — carries the IP in `subjectAltName`. The pilot pins the SHA-256 of
8//! the DER bytes, which the signaling cloud forwards from `announce`.
9
10use sha2::{Digest, Sha256};
11use std::net::IpAddr;
12use time::{Duration, OffsetDateTime};
13
14/// Validity chosen one day inside Chrome's 14-day limit.
15pub const VALIDITY_DAYS: i64 = 13;
16
17#[derive(Clone)]
18pub struct Cert {
19    pub cert_der: Vec<u8>,
20    /// PKCS#8 private key, DER.
21    pub key_der: Vec<u8>,
22    pub fingerprint_sha256: [u8; 32],
23    pub not_after: OffsetDateTime,
24}
25
26impl std::fmt::Debug for Cert {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        f.debug_struct("Cert")
29            .field("fingerprint", &self.fingerprint_hex())
30            .field("not_after", &self.not_after)
31            .finish()
32    }
33}
34
35impl Cert {
36    /// Generate an ECDSA P-256 certificate with every `ips` entry as a SAN.
37    pub fn generate(ips: &[IpAddr]) -> Result<Cert, rcgen::Error> {
38        let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)?;
39        let mut params = rcgen::CertificateParams::new(Vec::<String>::new())?;
40        params.subject_alt_names = ips
41            .iter()
42            .map(|ip| rcgen::SanType::IpAddress(*ip))
43            .collect();
44        let now = OffsetDateTime::now_utc();
45        // A minute of skew tolerance: a pilot clock slightly ahead of the robot
46        // must not reject a certificate that was just minted.
47        params.not_before = now - Duration::minutes(1);
48        params.not_after = now + Duration::days(VALIDITY_DAYS);
49        let mut dn = rcgen::DistinguishedName::new();
50        dn.push(rcgen::DnType::CommonName, "seyd-agent");
51        params.distinguished_name = dn;
52        let not_after = params.not_after;
53        let cert = params.self_signed(&key)?;
54        let cert_der = cert.der().to_vec();
55        let fingerprint_sha256: [u8; 32] = Sha256::digest(&cert_der).into();
56        Ok(Cert {
57            cert_der,
58            key_der: key.serialize_der(),
59            fingerprint_sha256,
60            not_after,
61        })
62    }
63
64    pub fn fingerprint_hex(&self) -> String {
65        hex::encode(self.fingerprint_sha256)
66    }
67
68    /// Days until expiry; rotation is due at day 10 of 13.
69    pub fn days_left(&self) -> f64 {
70        (self.not_after - OffsetDateTime::now_utc()).as_seconds_f64() / 86_400.0
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn generates_short_lived_ecdsa_cert() {
80        let c = Cert::generate(&["127.0.0.1".parse().unwrap(), "::1".parse().unwrap()]).unwrap();
81        assert_eq!(c.fingerprint_hex().len(), 64);
82        assert!(c.days_left() > 12.9 && c.days_left() <= 13.0);
83        assert!(!c.key_der.is_empty());
84    }
85}