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