Skip to main content

tf_types/
bridge_webauthn.rs

1//! WebAuthn bridge — Rust mirror of `tools/tf-types-ts/src/core/bridge-webauthn.ts`.
2//!
3//! Maps a structured WebAuthn credential to a TrustForge actor identity
4//! and back. The full attestation parser/verifier lives in
5//! `webauthn_attestation.rs`; this module is the thin surface the
6//! BridgeRegistry hands to callers (matching the TS API), so contracts
7//! that already carry a verified credential can be promoted to an
8//! `ActorIdentity` without re-running attestation.
9
10use serde::{Deserialize, Serialize};
11
12use crate::bridges::{Bridge, BridgeError, BridgeKind};
13use crate::generated::{
14    ActorIdentity, ActorIdentity_IdentityVersion, ActorType, AuthorityRoot, AuthorityRoot_Kind,
15    PublicKey, PublicKey_Purpose, TrustLevel,
16};
17
18#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
19pub struct WebAuthnCredential {
20    pub credential_id: String,
21    pub public_key: String,
22    pub algorithm: String,
23    pub rp_id: String,
24    pub user_handle: String,
25    #[serde(skip_serializing_if = "Option::is_none", default)]
26    pub aaguid: Option<String>,
27    #[serde(skip_serializing_if = "Option::is_none", default)]
28    pub attestation_format: Option<String>,
29    #[serde(skip_serializing_if = "Option::is_none", default)]
30    pub valid_from: Option<String>,
31    #[serde(skip_serializing_if = "Option::is_none", default)]
32    pub valid_until: Option<String>,
33}
34
35#[derive(Clone, Debug, Default)]
36pub struct WebAuthnBridgeConfig {
37    pub bridge_id: String,
38    pub trust_domain: String,
39    pub rp_id: String,
40    /// Optional algorithm allow-list. If set, accept rejects credentials
41    /// whose algorithm field is not in the list.
42    pub allowed_algorithms: Option<Vec<String>>,
43}
44
45pub struct WebAuthnBridge {
46    cfg: WebAuthnBridgeConfig,
47}
48
49impl WebAuthnBridge {
50    pub fn new(cfg: WebAuthnBridgeConfig) -> Self {
51        WebAuthnBridge { cfg }
52    }
53
54    /// Promote a structured WebAuthn credential to a TrustForge
55    /// ActorIdentity using the bridge's rp_id binding and algorithm
56    /// allow-list. Mirrors TS `WebAuthnBridge.accept`.
57    pub fn accept(&self, cred: &WebAuthnCredential) -> Result<ActorIdentity, BridgeError> {
58        if cred.public_key.is_empty() {
59            return Err(BridgeError::InvalidInput("missing public_key".into()));
60        }
61        if cred.rp_id.is_empty() {
62            return Err(BridgeError::InvalidInput("missing rp_id".into()));
63        }
64        if cred.user_handle.is_empty() {
65            return Err(BridgeError::InvalidInput("missing user_handle".into()));
66        }
67        if !self.cfg.rp_id.is_empty() && self.cfg.rp_id != cred.rp_id {
68            return Err(BridgeError::Rejected(format!(
69                "credential rp_id {} does not match bridge rp_id {}",
70                cred.rp_id, self.cfg.rp_id
71            )));
72        }
73        if let Some(allow) = &self.cfg.allowed_algorithms {
74            if !allow.iter().any(|a| a == &cred.algorithm) {
75                return Err(BridgeError::Rejected(format!(
76                    "algorithm {} is not in the bridge's allow-list",
77                    cred.algorithm
78                )));
79            }
80        }
81        let now = current_iso8601();
82        let actor_id = format!("tf:actor:human:{}/{}", cred.rp_id, slug(&cred.user_handle));
83        let identity = ActorIdentity {
84            identity_version: ActorIdentity_IdentityVersion::V1,
85            actor_id,
86            actor_type: ActorType::Human,
87            instance_id: None,
88            public_keys: vec![PublicKey {
89                key_id: cred.credential_id.clone(),
90                algorithm: cred.algorithm.clone(),
91                public_key: cred.public_key.clone(),
92                purpose: PublicKey_Purpose::Signing,
93                valid_from: cred.valid_from.clone(),
94                valid_until: cred.valid_until.clone(),
95            }],
96            trust_levels: vec![TrustLevel::T4],
97            authority_roots: vec![AuthorityRoot {
98                kind: AuthorityRoot_Kind::HardwareKey,
99                id: cred
100                    .aaguid
101                    .clone()
102                    .unwrap_or_else(|| "(unknown-aaguid)".to_string()),
103            }],
104            attestations: None,
105            valid_from: cred.valid_from.clone().unwrap_or(now),
106            valid_until: cred.valid_until.clone(),
107            revocation_ref: None,
108            signature: None,
109        };
110        Ok(identity)
111    }
112
113    /// Reverse projection. Mirrors TS `WebAuthnBridge.project`.
114    pub fn project(&self, identity: &ActorIdentity) -> Result<WebAuthnCredential, BridgeError> {
115        if !matches!(identity.actor_type, ActorType::Human) {
116            return Err(BridgeError::Unsupported(format!(
117                "WebAuthn bridge only reverses human actors, got {:?}",
118                identity.actor_type
119            )));
120        }
121        let hardware_root = identity
122            .authority_roots
123            .iter()
124            .find(|r| matches!(r.kind, AuthorityRoot_Kind::HardwareKey))
125            .ok_or_else(|| {
126                BridgeError::Rejected(
127                    "identity's authority_roots does not include hardware-key".into(),
128                )
129            })?;
130        let key = identity
131            .public_keys
132            .first()
133            .ok_or_else(|| BridgeError::InvalidInput("identity has no public_keys".into()))?;
134        let (rp_id, user_handle) = parse_actor_uri(&identity.actor_id)?;
135        Ok(WebAuthnCredential {
136            credential_id: key.key_id.clone(),
137            public_key: key.public_key.clone(),
138            algorithm: key.algorithm.clone(),
139            rp_id,
140            user_handle,
141            aaguid: if hardware_root.id == "(unknown-aaguid)" {
142                None
143            } else {
144                Some(hardware_root.id.clone())
145            },
146            attestation_format: None,
147            valid_from: Some(identity.valid_from.clone()),
148            valid_until: identity.valid_until.clone(),
149        })
150    }
151}
152
153impl Bridge for WebAuthnBridge {
154    fn bridge_id(&self) -> &str {
155        &self.cfg.bridge_id
156    }
157    fn kind(&self) -> BridgeKind {
158        BridgeKind::Webauthn
159    }
160    fn trust_domain(&self) -> &str {
161        &self.cfg.trust_domain
162    }
163}
164
165fn slug(b64url: &str) -> String {
166    b64url
167        .trim_end_matches('=')
168        .replace('/', "_")
169        .replace('+', "-")
170}
171
172fn parse_actor_uri(uri: &str) -> Result<(String, String), BridgeError> {
173    // Matches `tf:actor:human:<rp_id>/<user_handle>`.
174    let rest = uri
175        .strip_prefix("tf:actor:human:")
176        .ok_or_else(|| BridgeError::InvalidInput(format!("malformed actor URI: {}", uri)))?;
177    let slash = rest
178        .find('/')
179        .ok_or_else(|| BridgeError::InvalidInput(format!("malformed actor URI: {}", uri)))?;
180    Ok((rest[..slash].to_string(), rest[slash + 1..].to_string()))
181}
182
183fn current_iso8601() -> String {
184    let secs = std::time::SystemTime::now()
185        .duration_since(std::time::UNIX_EPOCH)
186        .unwrap_or_default()
187        .as_secs() as i64;
188    let (year, month, day, hour, minute, second) = civil_from_unix(secs);
189    format!(
190        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
191        year, month, day, hour, minute, second
192    )
193}
194
195fn civil_from_unix(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
196    let days = secs.div_euclid(86_400);
197    let time = secs.rem_euclid(86_400);
198    let hour = (time / 3600) as u32;
199    let minute = ((time % 3600) / 60) as u32;
200    let second = (time % 60) as u32;
201    let z = days + 719_468;
202    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
203    let doe = (z - era * 146_097) as u64;
204    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
205    let y = yoe as i64 + era * 400;
206    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
207    let mp = (5 * doy + 2) / 153;
208    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
209    let m = if mp < 10 {
210        (mp + 3) as u32
211    } else {
212        (mp - 9) as u32
213    };
214    let year = if m <= 2 { y + 1 } else { y };
215    (year as i32, m, d, hour, minute, second)
216}