Skip to main content

igc_net/
access.rs

1//! Restricted-artifact fetch authorization.
2//!
3//! Implements the signed fetch proof described in `specs/60-keys-and-access.md §4`
4//! and the server-side monotonic `seq_num` store described in R-ACCESS-11.
5
6use std::io::Write as _;
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11use crate::util::is_lower_hex_64;
12
13const FETCH_REQUEST_SCHEMA: &str = "igc-net/fetch-request";
14const FETCH_REQUEST_SCHEMA_VERSION: u32 = 1;
15const GROUP_FETCH_REQUEST_SCHEMA: &str = "igc-net/group-fetch-request";
16const GROUP_FETCH_REQUEST_SCHEMA_VERSION: u32 = 1;
17const SEQ_NUM_DIRNAME: &str = "seq-nums";
18const GROUP_SEQ_NUM_DIRNAME: &str = "seq-nums-group";
19
20// ── ArtifactClass ─────────────────────────────────────────────────────────────
21
22/// The two restricted artifact classes that require a signed fetch proof.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ArtifactClass {
26    ProtectedRawCompanion,
27    PrivateRawIgc,
28}
29
30// ── FetchProof ────────────────────────────────────────────────────────────────
31
32/// Signed fetch proof transmitted by a requester to authorize access to a
33/// restricted artifact.  Corresponds to the wire JSON shape in §4.3.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct FetchProof {
36    pub schema: String,
37    pub schema_version: u32,
38    pub raw_igc_hash: String,
39    pub artifact_class: ArtifactClass,
40    pub requester_key: String,
41    pub seq_num: u64,
42    pub signature: String,
43}
44
45/// Payload signed by the requester — all `FetchProof` fields except `signature`.
46#[derive(Serialize)]
47struct FetchProofPayload<'a> {
48    schema: &'static str,
49    schema_version: u32,
50    raw_igc_hash: &'a str,
51    artifact_class: &'a ArtifactClass,
52    requester_key: &'a str,
53    seq_num: u64,
54}
55
56// ── Errors ────────────────────────────────────────────────────────────────────
57
58#[derive(Debug, thiserror::Error)]
59pub enum FetchProofError {
60    #[error("JSON: {0}")]
61    Json(#[from] serde_json::Error),
62    #[error("raw_igc_hash must be 64 lowercase hex chars")]
63    InvalidHash,
64    #[error("seq_num must be ≥ 1")]
65    SeqNumZero,
66    #[error("signature must be 128 lowercase hex chars")]
67    InvalidSignatureEncoding,
68    #[error("requester_key must be a valid 64-char lowercase hex Ed25519 public key")]
69    InvalidRequesterKey,
70    #[error("signature verification failed")]
71    SignatureVerification,
72    #[error("requester_key does not match the authorized public key")]
73    RequesterKeyMismatch,
74    #[error("signed artifact_class does not match the expected artifact class")]
75    ArtifactClassMismatch,
76    #[error("seq_num {got} is not strictly greater than last seen {last_seen}")]
77    SeqNumNotMonotonic { got: u64, last_seen: u64 },
78}
79
80// ── Signing ───────────────────────────────────────────────────────────────────
81
82/// Build and sign a fetch proof for a restricted artifact.
83///
84/// `seq_num` must be ≥ 1 and strictly greater than the serving node's
85/// last-accepted value for this `requester_key` (R-ACCESS-11).
86pub fn sign_fetch_proof(
87    raw_igc_hash: &str,
88    artifact_class: ArtifactClass,
89    seq_num: u64,
90    private_key: &iroh::SecretKey,
91) -> Result<FetchProof, FetchProofError> {
92    if !is_lower_hex_64(raw_igc_hash) {
93        return Err(FetchProofError::InvalidHash);
94    }
95    if seq_num == 0 {
96        return Err(FetchProofError::SeqNumZero);
97    }
98
99    let requester_key = private_key.public().to_string();
100    let payload = FetchProofPayload {
101        schema: FETCH_REQUEST_SCHEMA,
102        schema_version: FETCH_REQUEST_SCHEMA_VERSION,
103        raw_igc_hash,
104        artifact_class: &artifact_class,
105        requester_key: &requester_key,
106        seq_num,
107    };
108    let signing_bytes = json_canon::to_vec(&payload)?;
109    let signature = hex::encode(private_key.sign(&signing_bytes).to_bytes());
110
111    Ok(FetchProof {
112        schema: FETCH_REQUEST_SCHEMA.to_string(),
113        schema_version: FETCH_REQUEST_SCHEMA_VERSION,
114        raw_igc_hash: raw_igc_hash.to_string(),
115        artifact_class,
116        requester_key,
117        seq_num,
118        signature,
119    })
120}
121
122// ── Verification ──────────────────────────────────────────────────────────────
123
124/// Verify a fetch proof received by a serving node.
125///
126/// Checks (in order):
127/// 1. `requester_key` matches `authorized_public_key` (R-ACCESS-10)
128/// 2. `artifact_class` matches `expected_artifact_class` (R-ACCESS-28)
129/// 3. `seq_num ≥ 1` and `seq_num > last_seen_seq_num` (R-ACCESS-11)
130/// 4. Ed25519 signature is valid (R-ACCESS-08)
131///
132/// The caller MUST call `SeqNumStore::advance` and durably persist the new
133/// `seq_num` before transmitting any bytes (R-ACCESS-11, R-ACCESS-13).
134pub fn verify_fetch_proof(
135    proof: &FetchProof,
136    authorized_public_key: &iroh::PublicKey,
137    expected_artifact_class: &ArtifactClass,
138    last_seen_seq_num: u64,
139) -> Result<(), FetchProofError> {
140    // R-ACCESS-10: requester_key must match the authorized key.
141    if proof.requester_key != authorized_public_key.to_string() {
142        return Err(FetchProofError::RequesterKeyMismatch);
143    }
144
145    // R-ACCESS-28: signed artifact_class must match.
146    if &proof.artifact_class != expected_artifact_class {
147        return Err(FetchProofError::ArtifactClassMismatch);
148    }
149
150    // R-ACCESS-11: seq_num must be positive and strictly increasing.
151    if proof.seq_num == 0 {
152        return Err(FetchProofError::SeqNumZero);
153    }
154    if proof.seq_num <= last_seen_seq_num {
155        return Err(FetchProofError::SeqNumNotMonotonic {
156            got: proof.seq_num,
157            last_seen: last_seen_seq_num,
158        });
159    }
160
161    // R-ACCESS-09: raw_igc_hash must be well-formed.
162    if !is_lower_hex_64(&proof.raw_igc_hash) {
163        return Err(FetchProofError::InvalidHash);
164    }
165
166    // R-ACCESS-08: Ed25519 signature must be valid.
167    let signature = decode_signature_hex(&proof.signature)?;
168    let payload = FetchProofPayload {
169        schema: FETCH_REQUEST_SCHEMA,
170        schema_version: FETCH_REQUEST_SCHEMA_VERSION,
171        raw_igc_hash: &proof.raw_igc_hash,
172        artifact_class: &proof.artifact_class,
173        requester_key: &proof.requester_key,
174        seq_num: proof.seq_num,
175    };
176    let signing_bytes = json_canon::to_vec(&payload)?;
177    authorized_public_key
178        .verify(&signing_bytes, &signature)
179        .map_err(|_| FetchProofError::SignatureVerification)?;
180
181    Ok(())
182}
183
184fn decode_signature_hex(value: &str) -> Result<iroh::Signature, FetchProofError> {
185    if value.len() != 128
186        || !value
187            .bytes()
188            .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
189    {
190        return Err(FetchProofError::InvalidSignatureEncoding);
191    }
192    let bytes = hex::decode(value).map_err(|_| FetchProofError::InvalidSignatureEncoding)?;
193    let sig_bytes: [u8; 64] = bytes
194        .try_into()
195        .map_err(|_| FetchProofError::InvalidSignatureEncoding)?;
196    Ok(iroh::Signature::from_bytes(&sig_bytes))
197}
198
199// ── GroupFetchProof ───────────────────────────────────────────────────────────
200
201/// Signed group-based fetch proof.  The requester proves membership via their
202/// root pilot identity key (the pubkey embedded in `requester_pilot_id`).
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct GroupFetchProof {
205    pub schema: String,
206    pub schema_version: u32,
207    pub raw_igc_hash: String,
208    pub artifact_class: ArtifactClass,
209    pub requester_pilot_id: String,
210    pub group_id: String,
211    pub seq_num: u64,
212    pub signature: String,
213}
214
215/// Payload signed by the requester — all `GroupFetchProof` fields except `signature`.
216#[derive(Serialize)]
217struct GroupFetchProofPayload<'a> {
218    schema: &'static str,
219    schema_version: u32,
220    raw_igc_hash: &'a str,
221    artifact_class: &'a ArtifactClass,
222    requester_pilot_id: &'a str,
223    group_id: &'a str,
224    seq_num: u64,
225}
226
227// ── GroupFetchProofError ──────────────────────────────────────────────────────
228
229#[derive(Debug, thiserror::Error)]
230pub enum GroupFetchProofError {
231    #[error("JSON: {0}")]
232    Json(#[from] serde_json::Error),
233    #[error("raw_igc_hash must be 64 lowercase hex chars")]
234    InvalidHash,
235    #[error("seq_num must be ≥ 1")]
236    SeqNumZero,
237    #[error("signature must be 128 lowercase hex chars")]
238    InvalidSignatureEncoding,
239    #[error("requester_pilot_id is not a valid PilotId (expected igcnet:id:<64-hex>)")]
240    InvalidRequesterPilotId,
241    #[error("group_id is not a valid GroupId (expected igcnet:group:<32-hex>)")]
242    InvalidGroupId,
243    #[error("signature verification failed")]
244    SignatureVerification,
245    #[error("signed artifact_class does not match the expected artifact class")]
246    ArtifactClassMismatch,
247    #[error("seq_num {got} is not strictly greater than last seen {last_seen}")]
248    SeqNumNotMonotonic { got: u64, last_seen: u64 },
249}
250
251// ── Signing ───────────────────────────────────────────────────────────────────
252
253/// Build and sign a group-based fetch proof using the pilot's root identity key.
254pub fn sign_group_fetch_proof(
255    raw_igc_hash: &str,
256    artifact_class: ArtifactClass,
257    requester_pilot_id: &str,
258    group_id: &str,
259    seq_num: u64,
260    pilot_root_secret_key: &iroh::SecretKey,
261) -> Result<GroupFetchProof, GroupFetchProofError> {
262    if !is_lower_hex_64(raw_igc_hash) {
263        return Err(GroupFetchProofError::InvalidHash);
264    }
265    if seq_num == 0 {
266        return Err(GroupFetchProofError::SeqNumZero);
267    }
268    parse_pilot_id(requester_pilot_id)?;
269    parse_group_id(group_id)?;
270
271    let payload = GroupFetchProofPayload {
272        schema: GROUP_FETCH_REQUEST_SCHEMA,
273        schema_version: GROUP_FETCH_REQUEST_SCHEMA_VERSION,
274        raw_igc_hash,
275        artifact_class: &artifact_class,
276        requester_pilot_id,
277        group_id,
278        seq_num,
279    };
280    let signing_bytes = json_canon::to_vec(&payload)?;
281    let signature = hex::encode(pilot_root_secret_key.sign(&signing_bytes).to_bytes());
282
283    Ok(GroupFetchProof {
284        schema: GROUP_FETCH_REQUEST_SCHEMA.to_string(),
285        schema_version: GROUP_FETCH_REQUEST_SCHEMA_VERSION,
286        raw_igc_hash: raw_igc_hash.to_string(),
287        artifact_class,
288        requester_pilot_id: requester_pilot_id.to_string(),
289        group_id: group_id.to_string(),
290        seq_num,
291        signature,
292    })
293}
294
295// ── Verification ──────────────────────────────────────────────────────────────
296
297/// Verify a group-fetch proof received by a serving node.
298///
299/// Checks (in order):
300/// 1. `requester_pilot_id` is a valid PilotId
301/// 2. `artifact_class` matches `expected_artifact_class`
302/// 3. `seq_num ≥ 1` and `seq_num > last_seen_seq_num`
303/// 4. Ed25519 signature is valid (pubkey extracted from `requester_pilot_id`)
304pub fn verify_group_fetch_proof(
305    proof: &GroupFetchProof,
306    expected_artifact_class: &ArtifactClass,
307    last_seen_seq_num: u64,
308) -> Result<(), GroupFetchProofError> {
309    let authorized_public_key = parse_pilot_id(&proof.requester_pilot_id)?;
310    parse_group_id(&proof.group_id)?;
311
312    if &proof.artifact_class != expected_artifact_class {
313        return Err(GroupFetchProofError::ArtifactClassMismatch);
314    }
315    if proof.seq_num == 0 {
316        return Err(GroupFetchProofError::SeqNumZero);
317    }
318    if proof.seq_num <= last_seen_seq_num {
319        return Err(GroupFetchProofError::SeqNumNotMonotonic {
320            got: proof.seq_num,
321            last_seen: last_seen_seq_num,
322        });
323    }
324    if !is_lower_hex_64(&proof.raw_igc_hash) {
325        return Err(GroupFetchProofError::InvalidHash);
326    }
327
328    let signature = decode_signature_hex(&proof.signature)
329        .map_err(|_| GroupFetchProofError::InvalidSignatureEncoding)?;
330    let payload = GroupFetchProofPayload {
331        schema: GROUP_FETCH_REQUEST_SCHEMA,
332        schema_version: GROUP_FETCH_REQUEST_SCHEMA_VERSION,
333        raw_igc_hash: &proof.raw_igc_hash,
334        artifact_class: &proof.artifact_class,
335        requester_pilot_id: &proof.requester_pilot_id,
336        group_id: &proof.group_id,
337        seq_num: proof.seq_num,
338    };
339    let signing_bytes = json_canon::to_vec(&payload)?;
340    authorized_public_key
341        .verify(&signing_bytes, &signature)
342        .map_err(|_| GroupFetchProofError::SignatureVerification)?;
343
344    Ok(())
345}
346
347fn parse_pilot_id(pilot_id: &str) -> Result<iroh::PublicKey, GroupFetchProofError> {
348    let key_hex = pilot_id
349        .strip_prefix("igcnet:id:")
350        .filter(|h| is_lower_hex_64(h))
351        .ok_or(GroupFetchProofError::InvalidRequesterPilotId)?;
352    let bytes = hex::decode(key_hex).map_err(|_| GroupFetchProofError::InvalidRequesterPilotId)?;
353    let arr: [u8; 32] = bytes
354        .try_into()
355        .map_err(|_| GroupFetchProofError::InvalidRequesterPilotId)?;
356    iroh::PublicKey::from_bytes(&arr).map_err(|_| GroupFetchProofError::InvalidRequesterPilotId)
357}
358
359fn parse_group_id(group_id: &str) -> Result<(), GroupFetchProofError> {
360    let id_hex = group_id
361        .strip_prefix("igcnet:group:")
362        .ok_or(GroupFetchProofError::InvalidGroupId)?;
363    if id_hex.len() != 32 || !id_hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
364        return Err(GroupFetchProofError::InvalidGroupId);
365    }
366    Ok(())
367}
368
369// ── SeqNumStore ───────────────────────────────────────────────────────────────
370
371/// Server-side durable store for the last-accepted `seq_num` per requester key.
372///
373/// Backed by one JSON file per requester key under `{root}/`.  Writes are
374/// atomic (tmp-file + rename) and fsynced before rename so that a crash after
375/// bytes are transmitted cannot allow a replay.
376pub struct SeqNumStore {
377    root: PathBuf,
378}
379
380#[derive(Debug, thiserror::Error)]
381pub enum SeqNumStoreError {
382    #[error("I/O: {0}")]
383    Io(#[from] std::io::Error),
384    #[error("JSON: {0}")]
385    Json(#[from] serde_json::Error),
386    #[error("new seq_num {new} is not greater than stored {stored}")]
387    NotMonotonic { new: u64, stored: u64 },
388}
389
390#[derive(Serialize, Deserialize)]
391struct SeqNumRecord {
392    seq_num: u64,
393}
394
395impl SeqNumStore {
396    pub fn open(root: impl Into<PathBuf>) -> Self {
397        Self { root: root.into() }
398    }
399
400    pub fn for_data_dir(data_dir: impl AsRef<Path>) -> Self {
401        Self::open(data_dir.as_ref().join(SEQ_NUM_DIRNAME))
402    }
403
404    pub fn for_group_fetch_data_dir(data_dir: impl AsRef<Path>) -> Self {
405        Self::open(data_dir.as_ref().join(GROUP_SEQ_NUM_DIRNAME))
406    }
407
408    /// Return the last accepted `seq_num` for this requester key, or `0` if
409    /// the key has never been seen.
410    pub fn last_seen(&self, requester_key_hex: &str) -> Result<u64, SeqNumStoreError> {
411        let path = self.seq_file_path(requester_key_hex);
412        if !path.exists() {
413            return Ok(0);
414        }
415        let bytes = std::fs::read(&path)?;
416        let record: SeqNumRecord = serde_json::from_slice(&bytes)?;
417        Ok(record.seq_num)
418    }
419
420    /// Durably advance the stored `seq_num` for this requester key.
421    ///
422    /// Fsync + atomic rename ensures the update survives a crash (R-ACCESS-11).
423    /// Returns `SeqNumStoreError::NotMonotonic` if `new_seq_num <= current`.
424    pub fn advance(
425        &self,
426        requester_key_hex: &str,
427        new_seq_num: u64,
428    ) -> Result<(), SeqNumStoreError> {
429        let current = self.last_seen(requester_key_hex)?;
430        if new_seq_num <= current {
431            return Err(SeqNumStoreError::NotMonotonic {
432                new: new_seq_num,
433                stored: current,
434            });
435        }
436        self.write_seq_num(requester_key_hex, new_seq_num)
437    }
438
439    fn seq_file_path(&self, requester_key_hex: &str) -> PathBuf {
440        self.root.join(format!("{requester_key_hex}.json"))
441    }
442
443    fn write_seq_num(&self, requester_key_hex: &str, seq_num: u64) -> Result<(), SeqNumStoreError> {
444        std::fs::create_dir_all(&self.root)?;
445        let record = SeqNumRecord { seq_num };
446        let data = serde_json::to_vec(&record)?;
447        let tmp_name = format!(".{requester_key_hex}-{}.tmp", rand::random::<u64>());
448        let tmp_path = self.root.join(tmp_name);
449        {
450            let mut file = std::fs::OpenOptions::new()
451                .create(true)
452                .write(true)
453                .truncate(true)
454                .open(&tmp_path)?;
455            file.write_all(&data)?;
456            file.flush()?;
457            file.sync_all()?;
458        }
459        std::fs::rename(&tmp_path, self.seq_file_path(requester_key_hex))?;
460        Ok(())
461    }
462}
463
464// ── Tests ─────────────────────────────────────────────────────────────────────
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    fn secret_key(byte: u8) -> iroh::SecretKey {
471        iroh::SecretKey::from_bytes(&[byte; 32])
472    }
473
474    fn valid_hash() -> &'static str {
475        "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
476    }
477
478    // ── sign_fetch_proof ──────────────────────────────────────────────────────
479
480    #[test]
481    fn sign_round_trip_private_raw_igc() {
482        let key = secret_key(1);
483        let proof = sign_fetch_proof(valid_hash(), ArtifactClass::PrivateRawIgc, 1, &key).unwrap();
484
485        assert_eq!(proof.schema, "igc-net/fetch-request");
486        assert_eq!(proof.schema_version, 1);
487        assert_eq!(proof.raw_igc_hash, valid_hash());
488        assert_eq!(proof.artifact_class, ArtifactClass::PrivateRawIgc);
489        assert_eq!(proof.seq_num, 1);
490        assert_eq!(proof.requester_key, key.public().to_string());
491        assert_eq!(proof.signature.len(), 128);
492    }
493
494    #[test]
495    fn sign_round_trip_protected_raw_companion() {
496        let key = secret_key(2);
497        let proof =
498            sign_fetch_proof(valid_hash(), ArtifactClass::ProtectedRawCompanion, 42, &key).unwrap();
499        assert_eq!(proof.artifact_class, ArtifactClass::ProtectedRawCompanion);
500        assert_eq!(proof.seq_num, 42);
501    }
502
503    #[test]
504    fn sign_rejects_invalid_hash() {
505        let key = secret_key(3);
506        assert!(matches!(
507            sign_fetch_proof("not-a-hash", ArtifactClass::PrivateRawIgc, 1, &key),
508            Err(FetchProofError::InvalidHash)
509        ));
510        assert!(matches!(
511            sign_fetch_proof(&"a".repeat(63), ArtifactClass::PrivateRawIgc, 1, &key),
512            Err(FetchProofError::InvalidHash)
513        ));
514        assert!(matches!(
515            sign_fetch_proof(&"A".repeat(64), ArtifactClass::PrivateRawIgc, 1, &key),
516            Err(FetchProofError::InvalidHash)
517        ));
518    }
519
520    #[test]
521    fn sign_rejects_zero_seq_num() {
522        let key = secret_key(4);
523        assert!(matches!(
524            sign_fetch_proof(valid_hash(), ArtifactClass::PrivateRawIgc, 0, &key),
525            Err(FetchProofError::SeqNumZero)
526        ));
527    }
528
529    // ── verify_fetch_proof ────────────────────────────────────────────────────
530
531    #[test]
532    fn verify_accepts_valid_proof() {
533        let key = secret_key(10);
534        let proof = sign_fetch_proof(valid_hash(), ArtifactClass::PrivateRawIgc, 5, &key).unwrap();
535
536        verify_fetch_proof(&proof, &key.public(), &ArtifactClass::PrivateRawIgc, 4).unwrap();
537    }
538
539    #[test]
540    fn verify_rejects_wrong_requester_key() {
541        let signer = secret_key(11);
542        let other = secret_key(12);
543        let proof =
544            sign_fetch_proof(valid_hash(), ArtifactClass::PrivateRawIgc, 1, &signer).unwrap();
545
546        assert!(matches!(
547            verify_fetch_proof(&proof, &other.public(), &ArtifactClass::PrivateRawIgc, 0),
548            Err(FetchProofError::RequesterKeyMismatch)
549        ));
550    }
551
552    #[test]
553    fn verify_rejects_artifact_class_mismatch() {
554        let key = secret_key(13);
555        let proof = sign_fetch_proof(valid_hash(), ArtifactClass::PrivateRawIgc, 1, &key).unwrap();
556
557        assert!(matches!(
558            verify_fetch_proof(
559                &proof,
560                &key.public(),
561                &ArtifactClass::ProtectedRawCompanion,
562                0
563            ),
564            Err(FetchProofError::ArtifactClassMismatch)
565        ));
566    }
567
568    #[test]
569    fn verify_rejects_replayed_seq_num() {
570        let key = secret_key(14);
571        let proof = sign_fetch_proof(valid_hash(), ArtifactClass::PrivateRawIgc, 3, &key).unwrap();
572
573        // seq_num 3 equal to last_seen 3 — rejected
574        assert!(matches!(
575            verify_fetch_proof(&proof, &key.public(), &ArtifactClass::PrivateRawIgc, 3),
576            Err(FetchProofError::SeqNumNotMonotonic { .. })
577        ));
578        // seq_num 3 less than last_seen 4 — rejected
579        assert!(matches!(
580            verify_fetch_proof(&proof, &key.public(), &ArtifactClass::PrivateRawIgc, 4),
581            Err(FetchProofError::SeqNumNotMonotonic { .. })
582        ));
583    }
584
585    #[test]
586    fn verify_rejects_zero_seq_num() {
587        let key = secret_key(15);
588        // Fabricate a proof with seq_num = 0 (bypassing sign_fetch_proof validation)
589        let mut proof =
590            sign_fetch_proof(valid_hash(), ArtifactClass::PrivateRawIgc, 1, &key).unwrap();
591        proof.seq_num = 0;
592        assert!(matches!(
593            verify_fetch_proof(&proof, &key.public(), &ArtifactClass::PrivateRawIgc, 0),
594            Err(FetchProofError::SeqNumZero)
595        ));
596    }
597
598    #[test]
599    fn verify_rejects_tampered_signature() {
600        let key = secret_key(16);
601        let mut proof =
602            sign_fetch_proof(valid_hash(), ArtifactClass::PrivateRawIgc, 1, &key).unwrap();
603        // Flip one hex digit in the signature.
604        let last = proof.signature.pop().unwrap();
605        proof.signature.push(if last == 'a' { 'b' } else { 'a' });
606
607        assert!(matches!(
608            verify_fetch_proof(&proof, &key.public(), &ArtifactClass::PrivateRawIgc, 0),
609            Err(FetchProofError::SignatureVerification)
610        ));
611    }
612
613    #[test]
614    fn verify_rejects_tampered_seq_num() {
615        let key = secret_key(17);
616        let mut proof =
617            sign_fetch_proof(valid_hash(), ArtifactClass::PrivateRawIgc, 5, &key).unwrap();
618        // Attacker bumps seq_num after signing — signature no longer valid.
619        proof.seq_num = 6;
620
621        assert!(matches!(
622            verify_fetch_proof(&proof, &key.public(), &ArtifactClass::PrivateRawIgc, 0),
623            Err(FetchProofError::SignatureVerification)
624        ));
625    }
626
627    #[test]
628    fn verify_rejects_tampered_artifact_class() {
629        let key = secret_key(18);
630        let mut proof =
631            sign_fetch_proof(valid_hash(), ArtifactClass::PrivateRawIgc, 1, &key).unwrap();
632        // Attacker swaps artifact_class after signing.
633        proof.artifact_class = ArtifactClass::ProtectedRawCompanion;
634
635        assert!(matches!(
636            verify_fetch_proof(
637                &proof,
638                &key.public(),
639                &ArtifactClass::ProtectedRawCompanion,
640                0
641            ),
642            Err(FetchProofError::SignatureVerification)
643        ));
644    }
645
646    #[test]
647    fn proof_serializes_to_expected_json_field_names() {
648        let key = secret_key(19);
649        let proof =
650            sign_fetch_proof(valid_hash(), ArtifactClass::ProtectedRawCompanion, 1, &key).unwrap();
651        let json = serde_json::to_value(&proof).unwrap();
652        assert_eq!(json["schema"], "igc-net/fetch-request");
653        assert_eq!(json["schema_version"], 1);
654        assert_eq!(json["artifact_class"], "protected_raw_companion");
655        assert_eq!(json["seq_num"], 1);
656        assert!(json["signature"].as_str().unwrap().len() == 128);
657    }
658
659    // ── SeqNumStore ───────────────────────────────────────────────────────────
660
661    fn temp_seq_store() -> (SeqNumStore, tempfile::TempDir) {
662        let dir = tempfile::tempdir().unwrap();
663        let store = SeqNumStore::open(dir.path());
664        (store, dir)
665    }
666
667    const REQUESTER_KEY: &str = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
668
669    #[test]
670    fn last_seen_returns_zero_for_unknown_key() {
671        let (store, _dir) = temp_seq_store();
672        assert_eq!(store.last_seen(REQUESTER_KEY).unwrap(), 0);
673    }
674
675    #[test]
676    fn advance_and_last_seen_round_trip() {
677        let (store, _dir) = temp_seq_store();
678        store.advance(REQUESTER_KEY, 1).unwrap();
679        assert_eq!(store.last_seen(REQUESTER_KEY).unwrap(), 1);
680        store.advance(REQUESTER_KEY, 100).unwrap();
681        assert_eq!(store.last_seen(REQUESTER_KEY).unwrap(), 100);
682    }
683
684    #[test]
685    fn advance_rejects_equal_seq_num() {
686        let (store, _dir) = temp_seq_store();
687        store.advance(REQUESTER_KEY, 5).unwrap();
688        assert!(matches!(
689            store.advance(REQUESTER_KEY, 5),
690            Err(SeqNumStoreError::NotMonotonic { .. })
691        ));
692    }
693
694    #[test]
695    fn advance_rejects_lower_seq_num() {
696        let (store, _dir) = temp_seq_store();
697        store.advance(REQUESTER_KEY, 10).unwrap();
698        assert!(matches!(
699            store.advance(REQUESTER_KEY, 9),
700            Err(SeqNumStoreError::NotMonotonic { .. })
701        ));
702    }
703
704    #[test]
705    fn seq_num_survives_store_reopen() {
706        let dir = tempfile::tempdir().unwrap();
707        {
708            let store = SeqNumStore::open(dir.path());
709            store.advance(REQUESTER_KEY, 42).unwrap();
710        }
711        let store = SeqNumStore::open(dir.path());
712        assert_eq!(store.last_seen(REQUESTER_KEY).unwrap(), 42);
713    }
714
715    #[test]
716    fn seq_num_is_per_requester_key() {
717        let (store, _dir) = temp_seq_store();
718        let key_b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
719        store.advance(REQUESTER_KEY, 10).unwrap();
720        store.advance(key_b, 3).unwrap();
721        assert_eq!(store.last_seen(REQUESTER_KEY).unwrap(), 10);
722        assert_eq!(store.last_seen(key_b).unwrap(), 3);
723    }
724}