posemesh_compute_node/auth/
siwe.rs

1use chrono::{DateTime, Utc};
2use hex::FromHexError;
3use k256::ecdsa::{self, SigningKey};
4use k256::FieldBytes;
5use reqwest::Client;
6use serde::{Deserialize, Serialize};
7use sha3::{Digest, Keccak256};
8use thiserror::Error;
9
10const API_PREFIX: &str = "/internal/v1";
11const REQUEST_PATH: &str = "/auth/siwe/request";
12const VERIFY_PATH: &str = "/auth/siwe/verify";
13
14#[derive(Debug, Error)]
15pub enum SiweError {
16    #[error("invalid private key hex: {0}")]
17    InvalidHex(FromHexError),
18    #[error("invalid private key length: expected 32 bytes, got {0}")]
19    InvalidPrivateKeyLength(usize),
20    #[error("failed to initialize signing key: {0}")]
21    InvalidSigningKey(ecdsa::Error),
22    #[error("failed to sign SIWE message: {0}")]
23    Signing(ecdsa::Error),
24    #[error(transparent)]
25    Request(#[from] reqwest::Error),
26    #[error(transparent)]
27    InvalidExpiration(#[from] chrono::ParseError),
28    #[error("missing field '{0}' in response")]
29    MissingField(&'static str),
30}
31
32pub type Result<T> = std::result::Result<T, SiweError>;
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct AccessBundle {
36    token: String,
37    expires_at: DateTime<Utc>,
38}
39
40impl AccessBundle {
41    pub fn new(token: impl Into<String>, expires_at: DateTime<Utc>) -> Self {
42        Self {
43            token: token.into(),
44            expires_at,
45        }
46    }
47
48    pub fn token(&self) -> &str {
49        &self.token
50    }
51
52    pub fn expires_at(&self) -> DateTime<Utc> {
53        self.expires_at
54    }
55}
56
57pub fn sign_message(priv_hex: &str, message: &str) -> Result<String> {
58    let key_bytes = decode_priv_key(priv_hex)?;
59    let signing_key = signing_key_from_bytes(&key_bytes)?;
60    let digest = ethereum_message_digest(message);
61    let (signature, recovery_id) = signing_key
62        .sign_digest_recoverable(digest)
63        .map_err(SiweError::Signing)?;
64    let mut bytes = [0u8; 65];
65    let signature_bytes: [u8; 64] = signature.to_bytes().into();
66    bytes[..64].copy_from_slice(&signature_bytes);
67    // Ethereum expects recovery id in {27, 28} form.
68    let recovery_id = recovery_id.to_byte() + 27;
69    bytes[64] = recovery_id;
70    Ok(format!("0x{}", hex::encode(bytes)))
71}
72
73/// Fields returned by DDS SIWE nonce endpoint. These should be used to build
74/// a standards-compliant SIWE message string for signing.
75#[derive(Deserialize, Debug, Clone)]
76pub struct SiweRequestMeta {
77    pub nonce: Option<String>,
78    pub domain: Option<String>,
79    pub uri: Option<String>,
80    pub version: Option<String>,
81    #[serde(rename = "chainId")]
82    pub chain_id: Option<i64>,
83    #[serde(rename = "issuedAt")]
84    pub issued_at: Option<String>,
85}
86
87/// Request a SIWE nonce from DDS, binding it to the provided wallet address.
88pub async fn request_nonce(base_url: &str, wallet: &str) -> Result<SiweRequestMeta> {
89    let client = new_client()?;
90    let url = endpoint(base_url, REQUEST_PATH);
91    let response = client
92        .post(url)
93        .json(&serde_json::json!({ "wallet": wallet }))
94        .send()
95        .await?
96        .error_for_status()?;
97    let body: SiweRequestMeta = response.json().await?;
98    if body.nonce.as_deref().unwrap_or("").is_empty() {
99        return Err(SiweError::MissingField("nonce"));
100    }
101    Ok(body)
102}
103
104#[derive(Serialize)]
105struct VerifyRequest<'a> {
106    address: &'a str,
107    message: &'a str,
108    signature: &'a str,
109}
110
111#[derive(Deserialize)]
112struct VerifyResponse {
113    access_token: Option<String>,
114    expires_at: Option<String>,
115    access_expires_at: Option<String>,
116}
117
118pub async fn verify(
119    base_url: &str,
120    address: &str,
121    message: &str,
122    signature: &str,
123) -> Result<AccessBundle> {
124    let client = new_client()?;
125    let url = endpoint(base_url, VERIFY_PATH);
126    let payload = VerifyRequest {
127        address,
128        message,
129        signature,
130    };
131    let response = client
132        .post(url)
133        .json(&payload)
134        .send()
135        .await?
136        .error_for_status()?;
137    let body: VerifyResponse = response.json().await?;
138    let token = body
139        .access_token
140        .filter(|value| !value.is_empty())
141        .ok_or(SiweError::MissingField("access_token"))?;
142    let expires_at_raw = body
143        .access_expires_at
144        .or(body.expires_at)
145        .ok_or(SiweError::MissingField("access_expires_at"))?;
146    let expires_at = DateTime::parse_from_rfc3339(&expires_at_raw)?.with_timezone(&Utc);
147    Ok(AccessBundle { token, expires_at })
148}
149
150fn decode_priv_key(priv_hex: &str) -> Result<[u8; 32]> {
151    let trimmed = priv_hex.strip_prefix("0x").unwrap_or(priv_hex);
152    let bytes = hex::decode(trimmed).map_err(SiweError::InvalidHex)?;
153    if bytes.len() != 32 {
154        return Err(SiweError::InvalidPrivateKeyLength(bytes.len()));
155    }
156    let mut result = [0u8; 32];
157    result.copy_from_slice(&bytes);
158    Ok(result)
159}
160
161fn endpoint(base_url: &str, path: &str) -> String {
162    let base = base_url.trim_end_matches('/');
163    format!("{base}{API_PREFIX}{path}")
164}
165
166fn ethereum_message_digest(message: &str) -> Keccak256 {
167    let mut digest = Keccak256::new();
168    let prefix = format!("\u{19}Ethereum Signed Message:\n{}", message.len());
169    digest.update(prefix.as_bytes());
170    digest.update(message.as_bytes());
171    digest
172}
173
174fn new_client() -> Result<Client> {
175    Client::builder()
176        .no_proxy()
177        .build()
178        .map_err(SiweError::Request)
179}
180
181fn signing_key_from_bytes(bytes: &[u8; 32]) -> Result<SigningKey> {
182    let field_bytes: FieldBytes = (*bytes).into();
183    SigningKey::from_bytes(&field_bytes).map_err(SiweError::InvalidSigningKey)
184}
185
186/// Compose a SIWE message string using values returned from DDS and the signer
187/// address. Optionally include a list of resource URNs.
188pub fn compose_message(
189    meta: &SiweRequestMeta,
190    address: &str,
191    resources: Option<&[&str]>,
192) -> Result<String> {
193    let domain = meta
194        .domain
195        .as_deref()
196        .ok_or(SiweError::MissingField("domain"))?;
197    let uri = meta.uri.as_deref().ok_or(SiweError::MissingField("uri"))?;
198    let version = meta
199        .version
200        .as_deref()
201        .ok_or(SiweError::MissingField("version"))?;
202    let chain_id = meta.chain_id.ok_or(SiweError::MissingField("chainId"))?;
203    let nonce = meta
204        .nonce
205        .as_deref()
206        .ok_or(SiweError::MissingField("nonce"))?;
207    let issued_at = meta
208        .issued_at
209        .as_deref()
210        .ok_or(SiweError::MissingField("issuedAt"))?;
211
212    let mut out = String::new();
213    out.push_str(&format!(
214        "{} wants you to sign in with your Ethereum account:\n",
215        domain
216    ));
217    out.push_str(address);
218    out.push_str("\n\n");
219    out.push_str(&format!("URI: {}\n", uri));
220    out.push_str(&format!("Version: {}\n", version));
221    out.push_str(&format!("Chain ID: {}\n", chain_id));
222    out.push_str(&format!("Nonce: {}\n", nonce));
223    out.push_str(&format!("Issued At: {}", issued_at));
224    if let Some(list) = resources {
225        if !list.is_empty() {
226            out.push_str("\nResources:\n");
227            for r in list {
228                out.push_str(&format!("- {}\n", r));
229            }
230            if out.ends_with('\n') {
231                out.pop();
232            }
233        }
234    }
235    Ok(out)
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    const TEST_PRIV_HEX: &str = "4c0883a69102937d6231471b5dbb6204fe5129617082798ce3f4fdf2548b6f90";
243    const TEST_ADDRESS: &str = "0xfdbb6caf01414300c16ea14859fec7736d95355f";
244
245    #[test]
246    fn signs_message_with_expected_signature() {
247        let message = format!(
248            "example.com wants you to sign in with your Ethereum account:\n{}\n\nURI: https://example.com\nVersion: 1\nChain ID: 1\nNonce: abc123\nIssued At: 2024-05-01T00:00:00Z",
249            TEST_ADDRESS
250        );
251
252        let signature = sign_message(TEST_PRIV_HEX, &message).expect("signature");
253
254        let expected_signature = "0x390786f1c4840ec337aef7c4a6d15bba128cc308e2f32f4528e2f8dd44f61f354a68a45bcf7d39eb724346a02f6eefebd8a39e20ebf68435bfe026ed47b1e5171b";
255        assert_eq!(signature, expected_signature);
256    }
257
258    #[test]
259    fn compose_message_includes_resources() {
260        let meta = SiweRequestMeta {
261            nonce: Some("nonce".into()),
262            domain: Some("example.com".into()),
263            uri: Some("https://example.com".into()),
264            version: Some("1".into()),
265            chain_id: Some(1),
266            issued_at: Some("2024-05-01T00:00:00Z".into()),
267        };
268        let msg =
269            compose_message(&meta, TEST_ADDRESS, Some(&["urn:foo", "urn:bar"])).expect("message");
270        assert!(msg.contains("Resources:\n- urn:foo\n- urn:bar"));
271    }
272}