posemesh_compute_node/auth/
siwe.rs1use 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 let recovery_id = recovery_id.to_byte() + 27;
82 bytes[64] = recovery_id;
83 Ok(format!("0x{}", hex::encode(bytes)))
84}
85
86#[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
100pub 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
199pub 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}