Skip to main content

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