1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use purecrypto::ec::Ed25519PrivateKey;
use purecrypto::hash::sha256;
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
use crate::error::{RestError, Result};
/// ApiKey represents an API key with its secret for signing requests.
#[derive(Clone)]
pub struct ApiKey {
/// API key identifier
pub key_id: String,
/// Ed25519 private key (seed) for signing
private_key: Ed25519PrivateKey,
}
impl ApiKey {
/// Create a new ApiKey from a key ID and base64-encoded secret
///
/// # Arguments
/// * `key_id` - The API key identifier
/// * `secret` - The base64url-encoded Ed25519 private key
pub fn new(key_id: String, secret: &str) -> Result<Self> {
// Try to decode as base64url first (URL_SAFE_NO_PAD)
let decoded = URL_SAFE_NO_PAD
.decode(secret)
.or_else(|_| {
// Fallback to standard base64
base64::engine::general_purpose::STANDARD.decode(secret)
})
.map_err(RestError::Base64Decode)?;
// Ed25519 secret keys are a 32-byte seed; a 64-byte input is the
// seed concatenated with the public key, so take the first 32 bytes.
if decoded.len() != 32 && decoded.len() != 64 {
return Err(RestError::Other(format!(
"Invalid key length: expected 32 or 64 bytes, got {}",
decoded.len()
)));
}
let mut seed = [0u8; 32];
seed.copy_from_slice(&decoded[..32]);
let private_key = Ed25519PrivateKey::from_bytes(seed);
Ok(ApiKey {
key_id,
private_key,
})
}
/// Generate a signature for a REST API request
///
/// # Arguments
/// * `method` - HTTP method (GET, POST, etc.)
/// * `path` - API endpoint path
/// * `query_params` - Query parameters as key-value pairs
/// * `body` - Request body bytes (if any)
fn generate_signature(
&self,
method: &str,
path: &str,
query_params: &HashMap<String, String>,
body: &[u8],
) -> Result<String> {
// Generate SHA256 hash of the request body
let body_hash = sha256(body);
// Build query string (excluding _sign parameter)
let mut params: Vec<(String, String)> = query_params
.iter()
.filter(|(k, _)| k.as_str() != "_sign")
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
// Sort parameters for consistent ordering
params.sort_by(|a, b| a.0.cmp(&b.0));
let query_string: String = form_urlencoded::Serializer::new(String::new())
.extend_pairs(params)
.finish();
// Build signing string with null byte separators
let mut sign_string = Vec::new();
sign_string.extend_from_slice(method.as_bytes());
sign_string.push(0);
sign_string.extend_from_slice(path.as_bytes());
sign_string.push(0);
sign_string.extend_from_slice(query_string.as_bytes());
sign_string.push(0);
sign_string.extend_from_slice(&body_hash);
// Sign using Ed25519
let signature = self.private_key.sign(&sign_string);
// Encode signature as base64url
let encoded = URL_SAFE_NO_PAD.encode(signature.to_bytes());
Ok(encoded)
}
/// Apply API key parameters to query parameters
///
/// Adds _key, _time, _nonce, and _sign parameters
pub(crate) fn apply_params(
&self,
method: &str,
path: &str,
params: &mut HashMap<String, String>,
body: &[u8],
) -> Result<()> {
// Add API key parameters
params.insert("_key".to_string(), self.key_id.clone());
// Add timestamp
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| RestError::Other(format!("system clock before unix epoch: {}", e)))?
.as_secs();
params.insert("_time".to_string(), timestamp.to_string());
// Add nonce
let nonce = Uuid::new_v4().to_string();
params.insert("_nonce".to_string(), nonce);
// Generate and add signature
let signature = self.generate_signature(method, path, params, body)?;
params.insert("_sign".to_string(), signature);
Ok(())
}
}
// Implement Debug manually to avoid exposing the secret key
impl std::fmt::Debug for ApiKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ApiKey")
.field("key_id", &self.key_id)
.field("private_key", &"<redacted>")
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_apikey_creation() {
// This is a test key - not a real one
let test_secret = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let _result = ApiKey::new("test-key".to_string(), test_secret);
// This may fail with invalid key length, which is expected for a dummy key
// In real usage, the secret would be a valid 32-byte Ed25519 key
}
#[test]
fn test_signature_generation() {
// A fixed 32-byte seed, base64url-encoded (no padding).
let seed = [7u8; 32];
let secret = URL_SAFE_NO_PAD.encode(seed);
let key = ApiKey::new("test-key".to_string(), &secret).unwrap();
let mut params = HashMap::new();
params.insert("foo".to_string(), "bar".to_string());
let sig = key
.generate_signature("GET", "Test/Path", ¶ms, b"body")
.unwrap();
// Ed25519 is deterministic, so the signature must be stable for fixed
// inputs. This pins the wire format (server-side verification depends on
// it). The value is RFC-8032 compliant for the seed above.
assert_eq!(
sig,
"d2J6a7VPiW2OFs9wJkBQ_l0vgXT4HStyG0NJTNmfM6OIsE7Wt9w0XbnuhByxVYFOtHDljykF_qK5z4mSCvimDg",
"signature changed — this would break server-side verification"
);
// Signing twice yields the same signature.
let sig2 = key
.generate_signature("GET", "Test/Path", ¶ms, b"body")
.unwrap();
assert_eq!(sig, sig2);
}
}