posemesh_compute_node/auth/
siwe.rs1use 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 let recovery_id = recovery_id.to_byte() + 27;
69 bytes[64] = recovery_id;
70 Ok(format!("0x{}", hex::encode(bytes)))
71}
72
73#[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
87pub 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
186pub 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}