Skip to main content

glass/browser/session/
identity.rs

1//! Opt-in RFC 9421-style declared agent identity signing.
2//!
3//! This module identifies an agent honestly; it does not spoof browser
4//! fingerprints, bypass bot protection, or alter the default navigation path.
5
6use super::*;
7use base64::Engine;
8use base64::engine::general_purpose::STANDARD;
9use ed25519_dalek::{Signer, SigningKey};
10
11const MAX_IDENTITY_TEXT_BYTES: usize = 256;
12
13/// An Ed25519 identity used for explicit HTTP Message Signature requests.
14/// The private key is retained only in this value and is never serialized.
15pub struct AgentIdentity {
16    pub agent: String,
17    pub key_id: String,
18    pub directory_url: String,
19    signing_key: SigningKey,
20}
21
22impl std::fmt::Debug for AgentIdentity {
23    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        formatter
25            .debug_struct("AgentIdentity")
26            .field("agent", &self.agent)
27            .field("key_id", &self.key_id)
28            .field("directory_url", &self.directory_url)
29            .finish_non_exhaustive()
30    }
31}
32
33/// Headers needed to send one signed HTTP request.
34#[derive(Debug, Clone, Serialize)]
35pub struct SignedHttpRequest {
36    #[serde(rename = "signatureAgent")]
37    pub signature_agent: String,
38    #[serde(rename = "signatureInput")]
39    pub signature_input: String,
40    pub signature: String,
41}
42
43impl AgentIdentity {
44    /// Load a 32-byte Ed25519 private key from standard base64.
45    pub fn from_base64(
46        agent: impl Into<String>,
47        key_id: impl Into<String>,
48        directory_url: impl Into<String>,
49        private_key_base64: &str,
50    ) -> BrowserResult<Self> {
51        let bytes = STANDARD.decode(private_key_base64.trim())?;
52        let key_bytes: [u8; 32] = bytes
53            .try_into()
54            .map_err(|_| "agent identity private key must decode to exactly 32 bytes")?;
55        let agent = bounded_identity_text(agent.into(), "agent")?;
56        let key_id = bounded_identity_text(key_id.into(), "key_id")?;
57        let directory_url = bounded_identity_text(directory_url.into(), "directory_url")?;
58        Ok(Self {
59            agent,
60            key_id,
61            directory_url,
62            signing_key: SigningKey::from_bytes(&key_bytes),
63        })
64    }
65
66    /// Sign the RFC 9421 covered components for one explicit request.
67    pub fn sign_request(
68        &self,
69        method: &str,
70        target_uri: &str,
71        content_digest: Option<&str>,
72        created: i64,
73    ) -> BrowserResult<SignedHttpRequest> {
74        let method = bounded_identity_text(method.to_ascii_lowercase(), "method")?;
75        let target_uri = bounded_identity_text(target_uri.to_string(), "target_uri")?;
76        if created < 0 {
77            return Err("signature created timestamp must be non-negative".into());
78        }
79        let mut covered = format!("\"@method\": {method}\n\"@target-uri\": {target_uri}");
80        let mut components = "(\"@method\" \"@target-uri\")".to_string();
81        if let Some(content_digest) = content_digest {
82            let digest = bounded_identity_text(content_digest.to_string(), "content_digest")?;
83            covered.push_str(&format!("\n\"content-digest\": {digest}"));
84            components = "(\"@method\" \"@target-uri\" \"content-digest\")".to_string();
85        }
86        let parameters = format!(
87            "{components};created={created};keyid=\"{}\";alg=\"ed25519\";tag=\"glass\"",
88            self.key_id
89        );
90        let signature = STANDARD.encode(self.signing_key.sign(covered.as_bytes()).to_bytes());
91        Ok(SignedHttpRequest {
92            signature_agent: format!("\"{}\"", self.agent),
93            signature_input: format!("sig1={parameters}"),
94            signature: format!("sig1=:{signature}:"),
95        })
96    }
97}
98
99impl BrowserSession {
100    /// Sign an explicitly supplied request after policy approval.
101    pub fn sign_http_request(
102        &self,
103        identity: &AgentIdentity,
104        method: &str,
105        target_uri: &str,
106        content_digest: Option<&str>,
107        created: i64,
108    ) -> BrowserResult<SignedHttpRequest> {
109        self.policy
110            .require(PolicyCapability::DeclaredAgentIdentity)?;
111        identity.sign_request(method, target_uri, content_digest, created)
112    }
113}
114
115fn bounded_identity_text(value: String, field: &str) -> BrowserResult<String> {
116    if value.is_empty() || value.len() > MAX_IDENTITY_TEXT_BYTES {
117        return Err(
118            format!("agent identity {field} must be 1..={MAX_IDENTITY_TEXT_BYTES} bytes").into(),
119        );
120    }
121    Ok(value)
122}