Skip to main content

shadowforge_lib/domain/canary/
mod.rs

1//! Canary shard tripwires for distribution compromise detection.
2//!
3//! A canary shard is an extra shard planted in a honeypot location. It cannot
4//! complete Reed-Solomon reconstruction on its own (not counted in K), but
5//! access to it signals that the distribution has been compromised.
6
7use sha2::{Digest, Sha256};
8use uuid::Uuid;
9
10use crate::domain::types::{CanaryShard, Shard};
11
12/// Generate a canary shard that looks plausible but cannot participate in
13/// real Reed-Solomon reconstruction.
14///
15/// The shard's `data` field contains a SHA-256 hash of the `canary_id`,
16/// allowing the distribution owner to identify it without revealing this
17/// to partial shard holders. The HMAC tag is derived with the `canary_id`
18/// mixed into the key so it will fail normal HMAC verification.
19pub fn generate_canary_shard(
20    canary_id: Uuid,
21    shard_size: usize,
22    total_shards: u8,
23    notify_url: Option<String>,
24) -> CanaryShard {
25    // Marker: SHA-256(canary_id) — repeated to fill shard_size
26    let marker = Sha256::digest(canary_id.as_bytes());
27    let mut data = Vec::with_capacity(shard_size);
28    while data.len() < shard_size {
29        let remaining = shard_size.saturating_sub(data.len());
30        let chunk = remaining.min(marker.len());
31        // SAFETY: chunk <= marker.len() by `.min()` above
32        #[expect(clippy::indexing_slicing, reason = "chunk bounded by marker.len()")]
33        data.extend_from_slice(&marker[..chunk]);
34    }
35
36    // Use total_shards as the index (one beyond the valid range 0..total_shards-1)
37    // This makes the canary shard's index invalid for RS reconstruction.
38    let canary_index = total_shards;
39
40    // HMAC tag: derive from canary_id mixed into a pseudo-key so it won't
41    // match any legitimate shard HMAC.
42    let mut hmac_input = Vec::new();
43    hmac_input.extend_from_slice(canary_id.as_bytes());
44    hmac_input.push(canary_index);
45    hmac_input.push(total_shards);
46    hmac_input.extend_from_slice(&data);
47    let hmac_tag: [u8; 32] = Sha256::digest(&hmac_input).into();
48
49    let shard = Shard {
50        index: canary_index,
51        total: total_shards,
52        data,
53        hmac_tag,
54    };
55
56    CanaryShard {
57        shard,
58        canary_id,
59        notify_url,
60    }
61}
62
63/// Check whether a given shard is the canary by verifying its data matches
64/// the SHA-256 marker derived from the `canary_id`.
65///
66/// This is a constant-time comparison to avoid timing side channels.
67pub fn is_canary_shard(shard: &Shard, canary_id: Uuid) -> bool {
68    use subtle::ConstantTimeEq;
69
70    let marker = Sha256::digest(canary_id.as_bytes());
71    if shard.data.len() < marker.len() {
72        return false;
73    }
74    // Compare the first 32 bytes of shard data against the marker
75    shard
76        .data
77        .get(..marker.len())
78        .is_some_and(|prefix| prefix.ct_eq(&marker).into())
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn canary_shard_has_correct_size() {
87        let canary_id = Uuid::new_v4();
88        let shard_size = 128;
89        let total = 5;
90
91        let canary = generate_canary_shard(canary_id, shard_size, total, None);
92
93        assert_eq!(canary.shard.data.len(), shard_size);
94        assert_eq!(canary.canary_id, canary_id);
95        assert!(canary.notify_url.is_none());
96    }
97
98    #[test]
99    fn canary_shard_index_is_beyond_valid_range() {
100        let canary_id = Uuid::new_v4();
101        let total = 5;
102
103        let canary = generate_canary_shard(canary_id, 64, total, None);
104
105        // Index should be total_shards (one beyond valid 0..total-1)
106        assert_eq!(canary.shard.index, total);
107    }
108
109    #[test]
110    fn canary_shard_data_contains_marker() {
111        let canary_id = Uuid::new_v4();
112        let canary = generate_canary_shard(canary_id, 64, 5, None);
113
114        assert!(is_canary_shard(&canary.shard, canary_id));
115    }
116
117    #[test]
118    fn canary_shard_not_detected_with_wrong_id() {
119        let canary_id = Uuid::new_v4();
120        let wrong_id = Uuid::new_v4();
121        let canary = generate_canary_shard(canary_id, 64, 5, None);
122
123        assert!(!is_canary_shard(&canary.shard, wrong_id));
124    }
125
126    #[test]
127    fn canary_shard_preserves_notify_url() {
128        let canary_id = Uuid::new_v4();
129        let url = "https://example.com/canary/abc123".to_owned();
130
131        let canary = generate_canary_shard(canary_id, 64, 5, Some(url.clone()));
132
133        assert_eq!(canary.notify_url.as_deref(), Some(url.as_str()));
134    }
135
136    #[test]
137    fn normal_shard_not_detected_as_canary() {
138        let canary_id = Uuid::new_v4();
139        let normal = Shard {
140            index: 0,
141            total: 5,
142            data: vec![0u8; 64],
143            hmac_tag: [0u8; 32],
144        };
145
146        assert!(!is_canary_shard(&normal, canary_id));
147    }
148
149    #[test]
150    fn different_canary_ids_produce_different_shards() {
151        let id1 = Uuid::new_v4();
152        let id2 = Uuid::new_v4();
153
154        let c1 = generate_canary_shard(id1, 64, 5, None);
155        let c2 = generate_canary_shard(id2, 64, 5, None);
156
157        assert_ne!(c1.shard.data, c2.shard.data);
158        assert_ne!(c1.shard.hmac_tag, c2.shard.hmac_tag);
159    }
160}