gatewarden 0.1.2

Hardened Keygen.sh license validation infrastructure
Documentation
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
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
//! Authenticated cache record format.
//!
//! The cache record stores all data needed to re-verify a Keygen response:
//! - Original response body
//! - HTTP headers needed for signature verification (Date, Keygen-Signature, Digest)
//! - Timestamp when the record was cached
//!
//! On load, we:
//! 1. Re-verify the signature (required)
//! 2. Compare digest if present
//! 3. Check `now - cached_at <= offline_grace`

use crate::clock::Clock;
use crate::crypto::{
    digest::verify_digest,
    signing::build_signing_string,
    verify::{decode_public_key, parse_signature_header, verify_ed25519},
};
use crate::GatewardenError;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::time::Duration;

/// Authenticated cache record containing all data needed to re-verify.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheRecord {
    /// The original HTTP Date header value.
    pub date: String,

    /// The original Keygen-Signature header value.
    pub signature: String,

    /// The original Digest header value (optional).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub digest: Option<String>,

    /// The original response body (JSON).
    pub body: String,

    /// When this record was cached (ISO 8601).
    pub cached_at: DateTime<Utc>,

    /// Request path used for signing string reconstruction.
    /// E.g., "/v1/accounts/{account}/licenses/{id}/actions/validate"
    pub request_path: String,

    /// Host used for signing string reconstruction.
    pub host: String,
}

impl CacheRecord {
    /// Create a new cache record from response data.
    pub fn new(
        date: String,
        signature: String,
        digest: Option<String>,
        body: String,
        request_path: String,
        host: String,
        clock: &dyn Clock,
    ) -> Self {
        Self {
            date,
            signature,
            digest,
            body,
            cached_at: clock.now_utc(),
            request_path,
            host,
        }
    }

    /// Serialize the cache record to JSON.
    pub fn to_json(&self) -> Result<String, GatewardenError> {
        serde_json::to_string_pretty(self)
            .map_err(|e| GatewardenError::CacheIO(format!("Failed to serialize cache: {}", e)))
    }

    /// Deserialize a cache record from JSON.
    pub fn from_json(json: &str) -> Result<Self, GatewardenError> {
        serde_json::from_str(json)
            .map_err(|e| GatewardenError::CacheIO(format!("Failed to deserialize cache: {}", e)))
    }

    /// Verify the cached record is authentic and within offline grace.
    ///
    /// This performs:
    /// 1. Signature verification (required)
    /// 2. Digest comparison (if present)
    /// 3. Offline grace check
    ///
    /// Note: We do NOT apply the 5-minute replay window to cached records.
    /// The `offline_grace` parameter controls how long cached data is valid.
    pub fn verify(
        &self,
        public_key_hex: &str,
        offline_grace: Duration,
        clock: &dyn Clock,
    ) -> Result<(), GatewardenError> {
        // 1. Parse signature header
        let parsed_sig = parse_signature_header(&self.signature)?;

        // 2. Decode public key
        let verifying_key = decode_public_key(public_key_hex)?;

        // 3. Reconstruct signing string
        // For POST validate requests, Keygen signs: (request-target), host, date, digest
        let signing_string = build_signing_string(
            "post",
            &self.request_path,
            &self.host,
            &self.date,
            self.digest.as_deref(),
        );

        // 4. Verify Ed25519 signature
        verify_ed25519(&parsed_sig.signature, &signing_string, &verifying_key)
            .map_err(|_| GatewardenError::CacheTampered)?;

        // 5. Verify digest if present
        if let Some(ref digest_header) = self.digest {
            verify_digest(self.body.as_bytes(), Some(digest_header))
                .map_err(|_| GatewardenError::CacheTampered)?;
        }

        // 6. Check offline grace period
        let now = clock.now_utc();
        let age = now.signed_duration_since(self.cached_at);
        let grace_secs = offline_grace.as_secs() as i64;

        if age.num_seconds() > grace_secs {
            return Err(GatewardenError::CacheExpired);
        }

        // Also reject if cached_at is in the future (clock tampering)
        if age.num_seconds() < 0 {
            return Err(GatewardenError::CacheTampered);
        }

        Ok(())
    }

    /// Extract the cached response body.
    pub fn body(&self) -> &str {
        &self.body
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::clock::MockClock;
    use crate::crypto::digest::format_digest_header;
    use base64::{engine::general_purpose::STANDARD, Engine};
    use chrono::TimeZone;
    use ed25519_dalek::{Signer, SigningKey};

    // Test signing seed + verifying key (DO NOT USE IN PRODUCTION)
    // This is a well-known Ed25519 test vector seed.
    const TEST_SIGNING_SEED_BYTES: [u8; 32] = [
        0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec, 0x2c,
        0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03, 0x1c, 0xae,
        0x7f, 0x60,
    ];
    const TEST_VERIFY_KEY_HEX: &str =
        "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a";

    fn get_test_signing_key() -> SigningKey {
        SigningKey::from_bytes(&TEST_SIGNING_SEED_BYTES)
    }

    fn sign_test_data(signing_string: &str) -> String {
        let signing_key = get_test_signing_key();
        let signature = signing_key.sign(signing_string.as_bytes());
        STANDARD.encode(signature.to_bytes())
    }

    fn create_test_record(
        body: &str,
        date: &str,
        host: &str,
        path: &str,
        clock: &MockClock,
    ) -> CacheRecord {
        let digest = format_digest_header(body.as_bytes());
        let signing_string = build_signing_string("post", path, host, date, Some(&digest));
        let signature_b64 = sign_test_data(&signing_string);
        let signature_header = format!(r#"algorithm="ed25519", signature="{}""#, signature_b64);

        CacheRecord::new(
            date.to_string(),
            signature_header,
            Some(digest),
            body.to_string(),
            path.to_string(),
            host.to_string(),
            clock,
        )
    }

    #[test]
    fn test_cache_record_roundtrip() {
        let clock = MockClock::new(Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap());
        let body = r#"{"data":{"type":"licenses","attributes":{"valid":true}}}"#;
        let record = create_test_record(
            body,
            "Wed, 15 Jan 2025 12:00:00 GMT",
            "api.keygen.sh",
            "/v1/accounts/test/licenses/abc/actions/validate",
            &clock,
        );

        let json = record.to_json().unwrap();
        let restored = CacheRecord::from_json(&json).unwrap();

        assert_eq!(restored.body, body);
        assert_eq!(restored.date, record.date);
        assert_eq!(restored.signature, record.signature);
        assert_eq!(restored.digest, record.digest);
    }

    #[test]
    fn test_cache_record_verify_valid() {
        let clock = MockClock::new(Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap());
        let body = r#"{"data":{"type":"licenses","attributes":{"valid":true}}}"#;
        let record = create_test_record(
            body,
            "Wed, 15 Jan 2025 12:00:00 GMT",
            "api.keygen.sh",
            "/v1/accounts/test/licenses/abc/actions/validate",
            &clock,
        );

        // Verify immediately - should pass
        let result = record.verify(
            TEST_VERIFY_KEY_HEX,
            Duration::from_secs(86400), // 24 hours grace
            &clock,
        );
        assert!(result.is_ok());
    }

    #[test]
    fn test_cache_record_verify_within_grace() {
        let clock = MockClock::new(Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap());
        let body = r#"{"data":{"type":"licenses","attributes":{"valid":true}}}"#;
        let record = create_test_record(
            body,
            "Wed, 15 Jan 2025 12:00:00 GMT",
            "api.keygen.sh",
            "/v1/accounts/test/licenses/abc/actions/validate",
            &clock,
        );

        // Advance 23 hours (within 24-hour grace)
        let later_clock = MockClock::new(Utc.with_ymd_and_hms(2025, 1, 16, 11, 0, 0).unwrap());
        let result = record.verify(
            TEST_VERIFY_KEY_HEX,
            Duration::from_secs(86400), // 24 hours grace
            &later_clock,
        );
        assert!(result.is_ok());
    }

    #[test]
    fn test_cache_record_verify_expired() {
        let clock = MockClock::new(Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap());
        let body = r#"{"data":{"type":"licenses","attributes":{"valid":true}}}"#;
        let record = create_test_record(
            body,
            "Wed, 15 Jan 2025 12:00:00 GMT",
            "api.keygen.sh",
            "/v1/accounts/test/licenses/abc/actions/validate",
            &clock,
        );

        // Advance 25 hours (beyond 24-hour grace)
        let later_clock = MockClock::new(Utc.with_ymd_and_hms(2025, 1, 16, 13, 0, 0).unwrap());
        let result = record.verify(
            TEST_VERIFY_KEY_HEX,
            Duration::from_secs(86400), // 24 hours grace
            &later_clock,
        );
        assert!(matches!(result, Err(GatewardenError::CacheExpired)));
    }

    #[test]
    fn test_cache_record_tampered_body() {
        let clock = MockClock::new(Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap());
        let body = r#"{"data":{"type":"licenses","attributes":{"valid":true}}}"#;
        let mut record = create_test_record(
            body,
            "Wed, 15 Jan 2025 12:00:00 GMT",
            "api.keygen.sh",
            "/v1/accounts/test/licenses/abc/actions/validate",
            &clock,
        );

        // Tamper with body
        record.body = r#"{"data":{"type":"licenses","attributes":{"valid":false}}}"#.to_string();

        let result = record.verify(TEST_VERIFY_KEY_HEX, Duration::from_secs(86400), &clock);
        assert!(matches!(result, Err(GatewardenError::CacheTampered)));
    }

    #[test]
    fn test_cache_record_tampered_date() {
        let clock = MockClock::new(Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap());
        let body = r#"{"data":{"type":"licenses","attributes":{"valid":true}}}"#;
        let mut record = create_test_record(
            body,
            "Wed, 15 Jan 2025 12:00:00 GMT",
            "api.keygen.sh",
            "/v1/accounts/test/licenses/abc/actions/validate",
            &clock,
        );

        // Tamper with date
        record.date = "Thu, 16 Jan 2025 12:00:00 GMT".to_string();

        let result = record.verify(TEST_VERIFY_KEY_HEX, Duration::from_secs(86400), &clock);
        assert!(matches!(result, Err(GatewardenError::CacheTampered)));
    }

    #[test]
    fn test_cache_record_tampered_signature() {
        let clock = MockClock::new(Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap());
        let body = r#"{"data":{"type":"licenses","attributes":{"valid":true}}}"#;
        let mut record = create_test_record(
            body,
            "Wed, 15 Jan 2025 12:00:00 GMT",
            "api.keygen.sh",
            "/v1/accounts/test/licenses/abc/actions/validate",
            &clock,
        );

        // Tamper with signature by using a completely different base64 value
        record.signature = r#"algorithm="ed25519", signature="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==""#.to_string();

        let result = record.verify(TEST_VERIFY_KEY_HEX, Duration::from_secs(86400), &clock);
        assert!(matches!(result, Err(GatewardenError::CacheTampered)));
    }

    #[test]
    fn test_cache_record_future_cached_at() {
        let clock = MockClock::new(Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap());
        let body = r#"{"data":{"type":"licenses","attributes":{"valid":true}}}"#;
        let record = create_test_record(
            body,
            "Wed, 15 Jan 2025 12:00:00 GMT",
            "api.keygen.sh",
            "/v1/accounts/test/licenses/abc/actions/validate",
            &clock,
        );

        // Verify with a clock that's BEFORE the cached_at time
        let past_clock = MockClock::new(Utc.with_ymd_and_hms(2025, 1, 15, 11, 0, 0).unwrap());
        let result = record.verify(TEST_VERIFY_KEY_HEX, Duration::from_secs(86400), &past_clock);
        assert!(matches!(result, Err(GatewardenError::CacheTampered)));
    }

    #[test]
    fn test_cache_record_no_digest() {
        let clock = MockClock::new(Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap());
        let body = r#"{"data":{"type":"licenses","attributes":{"valid":true}}}"#;
        let date = "Wed, 15 Jan 2025 12:00:00 GMT";
        let host = "api.keygen.sh";
        let path = "/v1/accounts/test/licenses/abc/actions/validate";

        // Sign without digest
        let signing_string = build_signing_string("post", path, host, date, None);
        let signature_b64 = sign_test_data(&signing_string);
        let signature_header = format!(r#"algorithm="ed25519", signature="{}""#, signature_b64);

        let record = CacheRecord::new(
            date.to_string(),
            signature_header,
            None, // No digest
            body.to_string(),
            path.to_string(),
            host.to_string(),
            &clock,
        );

        let result = record.verify(TEST_VERIFY_KEY_HEX, Duration::from_secs(86400), &clock);
        assert!(result.is_ok());
    }

    #[test]
    fn test_cache_record_grace_boundary() {
        let clock = MockClock::new(Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap());
        let body = r#"{"data":{"type":"licenses","attributes":{"valid":true}}}"#;
        let record = create_test_record(
            body,
            "Wed, 15 Jan 2025 12:00:00 GMT",
            "api.keygen.sh",
            "/v1/accounts/test/licenses/abc/actions/validate",
            &clock,
        );

        // Exactly at grace boundary (should pass)
        let boundary_clock = MockClock::new(Utc.with_ymd_and_hms(2025, 1, 16, 12, 0, 0).unwrap());
        let result = record.verify(
            TEST_VERIFY_KEY_HEX,
            Duration::from_secs(86400), // 24 hours
            &boundary_clock,
        );
        assert!(result.is_ok());

        // One second over (should fail)
        let over_clock = MockClock::new(Utc.with_ymd_and_hms(2025, 1, 16, 12, 0, 1).unwrap());
        let result = record.verify(TEST_VERIFY_KEY_HEX, Duration::from_secs(86400), &over_clock);
        assert!(matches!(result, Err(GatewardenError::CacheExpired)));
    }
}