Skip to main content

void_core/collab/manifest/
verify.rs

1//! Commit signature verification against contributor manifests.
2//!
3//! Verifies that commits are:
4//! 1. Properly signed with Ed25519
5//! 2. Signed by an authorized contributor
6//! 3. Pushing to an allowed ref path
7
8use crate::collab::Identity;
9use crate::metadata::Commit;
10
11use super::keys::SigningPubKey;
12#[cfg(test)]
13use super::keys::CommitSignature;
14use super::policy::{check_write_access, AuthResult};
15use super::types::Manifest;
16
17// ============================================================================
18// VerifyResult — Result of commit verification
19// ============================================================================
20
21/// Result of verifying a commit against a manifest.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum VerifyResult {
24    /// Commit signature is valid and signer is authorized.
25    Ok,
26    /// Commit signature is cryptographically invalid.
27    InvalidSignature,
28    /// Commit is not signed (no author/signature fields).
29    Unsigned,
30    /// Commit is signed but signer is not authorized for this ref.
31    Unauthorized {
32        /// The signing key that was used.
33        signer: SigningPubKey,
34        /// The ref path that was denied.
35        ref_path: String,
36    },
37}
38
39impl VerifyResult {
40    /// Returns true if the commit passed verification.
41    pub fn is_ok(&self) -> bool {
42        matches!(self, VerifyResult::Ok)
43    }
44
45    /// Returns true if the commit is unsigned (not an error in legacy mode).
46    pub fn is_unsigned(&self) -> bool {
47        matches!(self, VerifyResult::Unsigned)
48    }
49}
50
51// ============================================================================
52// Verification functions
53// ============================================================================
54
55/// Verify a single commit against a manifest.
56///
57/// # Arguments
58/// * `commit` - The commit to verify.
59/// * `ref_path` - The ref path being pushed to.
60/// * `manifest` - Optional manifest. If None, only signature validity is checked.
61///
62/// # Returns
63/// * `VerifyResult::Ok` — Commit is valid and authorized (or no manifest).
64/// * `VerifyResult::InvalidSignature` — Ed25519 signature failed verification.
65/// * `VerifyResult::Unsigned` — Commit has no signature.
66/// * `VerifyResult::Unauthorized` — Signer not allowed to push to this ref.
67pub fn verify_commit(
68    commit: &Commit,
69    ref_path: &str,
70    manifest: Option<&Manifest>,
71) -> VerifyResult {
72    // First, verify the cryptographic signature
73    let signer = match verify_commit_signature(commit) {
74        SignatureResult::Valid(signer) => signer,
75        SignatureResult::Invalid => return VerifyResult::InvalidSignature,
76        SignatureResult::Unsigned => return VerifyResult::Unsigned,
77    };
78
79    // If no manifest, signature validity is sufficient
80    let Some(manifest) = manifest else {
81        return VerifyResult::Ok;
82    };
83
84    // Check write authorization
85    match check_write_access(manifest, &signer, ref_path) {
86        AuthResult::Allowed | AuthResult::Delegated { .. } => VerifyResult::Ok,
87        AuthResult::Denied => VerifyResult::Unauthorized {
88            signer,
89            ref_path: ref_path.to_string(),
90        },
91    }
92}
93
94/// Verify multiple commits against a manifest.
95///
96/// Returns the first failure encountered, or `VerifyResult::Ok` if all pass.
97///
98/// # Arguments
99/// * `commits` - Iterator over commits to verify.
100/// * `ref_path` - The ref path being pushed to.
101/// * `manifest` - Optional manifest for authorization checking.
102pub fn verify_commits<'a>(
103    commits: impl Iterator<Item = &'a Commit>,
104    ref_path: &str,
105    manifest: Option<&Manifest>,
106) -> VerifyResult {
107    for commit in commits {
108        let result = verify_commit(commit, ref_path, manifest);
109        if !result.is_ok() {
110            return result;
111        }
112    }
113    VerifyResult::Ok
114}
115
116/// Verify only the cryptographic signature of a commit.
117///
118/// Does not check authorization. Useful for verifying commits
119/// where the manifest is not available.
120pub fn verify_signature_only(commit: &Commit) -> VerifyResult {
121    match verify_commit_signature(commit) {
122        SignatureResult::Valid(_) => VerifyResult::Ok,
123        SignatureResult::Invalid => VerifyResult::InvalidSignature,
124        SignatureResult::Unsigned => VerifyResult::Unsigned,
125    }
126}
127
128// ============================================================================
129// Internal signature verification
130// ============================================================================
131
132/// Internal result of signature verification.
133enum SignatureResult {
134    /// Signature is valid, returns the signer's key.
135    Valid(SigningPubKey),
136    /// Signature is cryptographically invalid.
137    Invalid,
138    /// Commit is unsigned.
139    Unsigned,
140}
141
142/// Verify the Ed25519 signature on a commit.
143fn verify_commit_signature(commit: &Commit) -> SignatureResult {
144    let (author, signature) = match (&commit.author, &commit.signature) {
145        (Some(a), Some(s)) => (a, s),
146        (None, None) => return SignatureResult::Unsigned,
147        // Partial signature (one field set, other missing) is invalid
148        _ => return SignatureResult::Invalid,
149    };
150
151    // Get the signable bytes
152    let signable = commit.signable_bytes();
153
154    // Verify using Identity::verify (static method)
155    if Identity::verify(author, &signable, signature.as_bytes()) {
156        SignatureResult::Valid(*author)
157    } else {
158        SignatureResult::Invalid
159    }
160}
161
162/// Extract the signer's public key from a signed commit.
163///
164/// Returns `None` if the commit is unsigned or has invalid signature fields.
165pub fn extract_signer(commit: &Commit) -> Option<SigningPubKey> {
166    commit.author
167}
168
169// ============================================================================
170// Tests
171// ============================================================================
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use ed25519_dalek::SigningKey;
177
178    fn test_signing_key(val: u8) -> SigningPubKey {
179        SigningPubKey::from_bytes([val; 32])
180    }
181
182    fn create_unsigned_commit() -> Commit {
183        Commit {
184            parents: vec![],
185            metadata_bundle: void_crypto::MetadataCid::from_bytes(vec![1, 2, 3]),
186            timestamp: 12345,
187            message: "test commit".to_string(),
188            author: None,
189            signature: None,
190            manifest_cid: None,
191            stats: None,
192            repo_manifest_cid: None,
193        }
194    }
195
196    fn create_signed_commit(signing_key: &SigningKey) -> Commit {
197        let mut commit = create_unsigned_commit();
198        commit.sign(signing_key);
199        commit
200    }
201
202    fn create_invalid_signed_commit() -> Commit {
203        let mut commit = create_unsigned_commit();
204        commit.author = Some(SigningPubKey::from_bytes([0xaa; 32]));
205        commit.signature = Some(CommitSignature::from_bytes([0xbb; 64])); // Invalid signature
206        commit
207    }
208
209    #[test]
210    fn verify_unsigned_commit() {
211        let commit = create_unsigned_commit();
212        let result = verify_commit(&commit, "refs/heads/main", None);
213        assert_eq!(result, VerifyResult::Unsigned);
214        assert!(result.is_unsigned());
215    }
216
217    #[test]
218    fn verify_valid_signature_no_manifest() {
219        let signing_key = SigningKey::from_bytes(&[0x42; 32]);
220        let commit = create_signed_commit(&signing_key);
221
222        let result = verify_commit(&commit, "refs/heads/main", None);
223        assert_eq!(result, VerifyResult::Ok);
224        assert!(result.is_ok());
225    }
226
227    #[test]
228    fn verify_invalid_signature() {
229        let commit = create_invalid_signed_commit();
230        let result = verify_commit(&commit, "refs/heads/main", None);
231        assert_eq!(result, VerifyResult::InvalidSignature);
232    }
233
234    #[test]
235    fn verify_authorized_signer() {
236        let signing_key = SigningKey::from_bytes(&[0x42; 32]);
237        let pubkey = SigningPubKey::from_bytes(signing_key.verifying_key().to_bytes());
238        let commit = create_signed_commit(&signing_key);
239
240        // Signer is the owner
241        let manifest = Manifest::new(pubkey, None);
242
243        let result = verify_commit(&commit, "refs/heads/main", Some(&manifest));
244        assert_eq!(result, VerifyResult::Ok);
245    }
246
247    #[test]
248    fn verify_unauthorized_signer() {
249        let signing_key = SigningKey::from_bytes(&[0x42; 32]);
250        let commit = create_signed_commit(&signing_key);
251
252        // Different owner
253        let manifest = Manifest::new(test_signing_key(0xaa), None);
254
255        let result = verify_commit(&commit, "refs/heads/main", Some(&manifest));
256        assert!(matches!(result, VerifyResult::Unauthorized { .. }));
257    }
258
259    #[test]
260    fn verify_commits_all_valid() {
261        let signing_key = SigningKey::from_bytes(&[0x42; 32]);
262        let commit1 = create_signed_commit(&signing_key);
263        let commit2 = create_signed_commit(&signing_key);
264        let commits = vec![commit1, commit2];
265
266        let result = verify_commits(commits.iter(), "refs/heads/main", None);
267        assert_eq!(result, VerifyResult::Ok);
268    }
269
270    #[test]
271    fn verify_commits_first_failure() {
272        let signing_key = SigningKey::from_bytes(&[0x42; 32]);
273        let valid_commit = create_signed_commit(&signing_key);
274        let invalid_commit = create_invalid_signed_commit();
275        let commits = vec![valid_commit, invalid_commit];
276
277        let result = verify_commits(commits.iter(), "refs/heads/main", None);
278        // Should return first failure (invalid signature on second commit)
279        assert_eq!(result, VerifyResult::InvalidSignature);
280    }
281
282    #[test]
283    fn extract_signer_from_signed() {
284        let signing_key = SigningKey::from_bytes(&[0x42; 32]);
285        let commit = create_signed_commit(&signing_key);
286
287        let signer = extract_signer(&commit);
288        assert!(signer.is_some());
289        assert_eq!(
290            signer.unwrap().as_bytes(),
291            &signing_key.verifying_key().to_bytes()
292        );
293    }
294
295    #[test]
296    fn extract_signer_from_unsigned() {
297        let commit = create_unsigned_commit();
298        let signer = extract_signer(&commit);
299        assert!(signer.is_none());
300    }
301
302    #[test]
303    fn verify_signature_only_valid() {
304        let signing_key = SigningKey::from_bytes(&[0x42; 32]);
305        let commit = create_signed_commit(&signing_key);
306
307        let result = verify_signature_only(&commit);
308        assert_eq!(result, VerifyResult::Ok);
309    }
310
311    #[test]
312    fn verify_signature_only_unsigned() {
313        let commit = create_unsigned_commit();
314        let result = verify_signature_only(&commit);
315        assert_eq!(result, VerifyResult::Unsigned);
316    }
317
318    #[test]
319    fn partial_signature_is_invalid() {
320        // Only author, no signature
321        let mut commit = create_unsigned_commit();
322        commit.author = Some(SigningPubKey::from_bytes([0xaa; 32]));
323        let result = verify_signature_only(&commit);
324        assert_eq!(result, VerifyResult::InvalidSignature);
325
326        // Only signature, no author
327        let mut commit = create_unsigned_commit();
328        commit.signature = Some(CommitSignature::from_bytes([0xbb; 64]));
329        let result = verify_signature_only(&commit);
330        assert_eq!(result, VerifyResult::InvalidSignature);
331    }
332
333    #[test]
334    fn verify_result_methods() {
335        assert!(VerifyResult::Ok.is_ok());
336        assert!(!VerifyResult::InvalidSignature.is_ok());
337        assert!(!VerifyResult::Unsigned.is_ok());
338
339        assert!(!VerifyResult::Ok.is_unsigned());
340        assert!(VerifyResult::Unsigned.is_unsigned());
341    }
342}