Skip to main content

calimero_node_primitives/bundle/
signature.rs

1//! Signature Verification Helpers
2//!
3//! This module provides helpers for verifying bundle manifest signatures
4//! - RFC 8785 JSON canonicalization (JCS)
5//! - SHA-256 signing payload computation
6//! - Ed25519 signature verification
7//! - did:key signerId derivation
8
9use base64::engine::general_purpose::URL_SAFE_NO_PAD;
10use base64::Engine;
11use ed25519_dalek::{Signature, Verifier, VerifyingKey};
12use eyre::{bail, ensure, Context, Result};
13use sha2::{Digest, Sha256};
14
15/// Multicodec indicator for ed25519-pub (0xed01, varint encoded).
16const ED25519_PUB_MULTICODEC: [u8; 2] = [0xed, 0x01];
17
18/// Result of manifest signature verification.
19#[derive(Debug, Clone)]
20pub struct ManifestVerification {
21    /// The derived signerId (did:key format).
22    pub signer_id: String,
23    /// The bundle hash (SHA-256 of canonical manifest bytes).
24    pub bundle_hash: [u8; 32],
25}
26
27/// Derives a did:key signerId from an Ed25519 public key.
28///
29/// The did:key format for Ed25519 is:
30/// - `did:key:` prefix
31/// - multibase base58btc encoding ('z' prefix)
32/// - multicodec indicator for ed25519-pub (0xed01)
33/// - the 32-byte Ed25519 public key
34///
35/// # Arguments
36/// * `pubkey` - The 32-byte Ed25519 public key
37///
38/// # Returns
39/// The did:key string (e.g., `did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK`)
40pub fn derive_signer_id_did_key(pubkey: &[u8; 32]) -> String {
41    // Construct the multicodec-prefixed key
42    let mut multicodec_key = Vec::with_capacity(2 + 32);
43    multicodec_key.extend_from_slice(&ED25519_PUB_MULTICODEC);
44    multicodec_key.extend_from_slice(pubkey);
45
46    // Encode with base58btc (multibase 'z' prefix)
47    let encoded = bs58::encode(&multicodec_key).into_string();
48
49    format!("did:key:z{}", encoded)
50}
51
52/// Canonicalizes a manifest JSON value using RFC 8785 (JCS).
53///
54/// The `signature` field MUST be excluded before canonicalization.
55/// This function clones the input and removes the signature field.
56///
57/// # Arguments
58/// * `manifest_json` - The manifest as a serde_json::Value
59///
60/// # Returns
61/// The canonical JSON bytes
62pub fn canonicalize_manifest(manifest_json: &serde_json::Value) -> Result<Vec<u8>> {
63    // Clone and remove the signature field for canonicalization
64    let mut signing_view = manifest_json.clone();
65    if let Some(obj) = signing_view.as_object_mut() {
66        obj.remove("signature");
67        // Remove all underscore-prefixed fields to prevent signature confusion.
68        // The underscore-prefix convention is reserved for transient/unsigned fields that
69        // should not be included in signature verification. Known transient fields include:
70        // - _binary: Binary data references (not part of canonical JSON)
71        // - _overwrite: Overwrite flags for migration artifacts
72        // Any future fields starting with underscore are also stripped to prevent signature
73        // confusion attacks where fields could be canonicalized and signed but ignored during
74        // processing, potentially leading to replay attacks.
75        obj.retain(|k, _| !k.starts_with('_'));
76    }
77
78    // Canonicalize using RFC 8785 JCS
79    let canonical_bytes = serde_json_canonicalizer::to_vec(&signing_view)
80        .context("failed to canonicalize manifest JSON")?;
81
82    Ok(canonical_bytes)
83}
84
85/// Computes the bundle hash from canonical manifest bytes.
86///
87/// `bundleHash = sha256(canonical_manifest_bytes)`
88///
89/// # Arguments
90/// * `canonical_bytes` - The RFC 8785 canonical manifest bytes (without signature)
91///
92/// # Returns
93/// The 32-byte SHA-256 hash
94pub fn compute_bundle_hash(canonical_bytes: &[u8]) -> [u8; 32] {
95    let mut hasher = Sha256::new();
96    hasher.update(canonical_bytes);
97    hasher.finalize().into()
98}
99
100/// Computes the signing payload from canonical manifest bytes.
101///
102/// `signingPayload = sha256(canonical_manifest_bytes_without_signature)`
103/// Note: In v0, signingPayload equals bundleHash.
104///
105/// # Arguments
106/// * `canonical_bytes` - The RFC 8785 canonical manifest bytes (without signature)
107///
108/// # Returns
109/// The 32-byte signing payload
110pub fn compute_signing_payload(canonical_bytes: &[u8]) -> [u8; 32] {
111    // In v0, signing payload equals bundle hash
112    compute_bundle_hash(canonical_bytes)
113}
114
115/// Decodes a base64url (no padding) encoded public key.
116///
117/// # Arguments
118/// * `encoded` - The base64url encoded string
119///
120/// # Returns
121/// The 32-byte Ed25519 public key
122pub fn decode_public_key(encoded: &str) -> Result<[u8; 32]> {
123    let bytes = URL_SAFE_NO_PAD
124        .decode(encoded)
125        .context("invalid base64url encoding for public key")?;
126
127    ensure!(
128        bytes.len() == 32,
129        "invalid public key length: expected 32 bytes, got {}",
130        bytes.len()
131    );
132
133    let mut key = [0u8; 32];
134    key.copy_from_slice(&bytes);
135    Ok(key)
136}
137
138/// Decodes a base64url (no padding) encoded signature.
139///
140/// # Arguments
141/// * `encoded` - The base64url encoded string
142///
143/// # Returns
144/// The 64-byte Ed25519 signature
145pub fn decode_signature(encoded: &str) -> Result<[u8; 64]> {
146    let bytes = URL_SAFE_NO_PAD
147        .decode(encoded)
148        .context("invalid base64url encoding for signature")?;
149
150    ensure!(
151        bytes.len() == 64,
152        "invalid signature length: expected 64 bytes, got {}",
153        bytes.len()
154    );
155
156    let mut sig = [0u8; 64];
157    sig.copy_from_slice(&bytes);
158    Ok(sig)
159}
160
161/// Verifies an Ed25519 signature over a message.
162///
163/// # Arguments
164/// * `signature_bytes` - The 64-byte Ed25519 signature
165/// * `public_key_bytes` - The 32-byte Ed25519 public key
166/// * `message` - The message that was signed
167///
168/// # Returns
169/// Ok(()) if verification succeeds, Err otherwise
170pub fn verify_ed25519(
171    signature_bytes: &[u8; 64],
172    public_key_bytes: &[u8; 32],
173    message: &[u8],
174) -> Result<()> {
175    let verifying_key =
176        VerifyingKey::from_bytes(public_key_bytes).context("invalid Ed25519 public key")?;
177
178    let signature = Signature::from_bytes(signature_bytes);
179
180    verifying_key
181        .verify(message, &signature)
182        .context("Ed25519 signature verification failed")?;
183
184    Ok(())
185}
186
187/// Verifies a bundle manifest signature.
188///
189/// This function performs the complete verification flow:
190/// 1. Extracts and validates the signature object
191/// 2. Decodes the base64url public key and signature
192/// 3. Canonicalizes the manifest (excluding signature field) using RFC 8785
193/// 4. Computes the signing payload (SHA-256 of canonical bytes)
194/// 5. Verifies the Ed25519 signature
195/// 6. Derives the signerId (did:key) from the public key
196/// 7. Validates that the derived signerId matches the manifest's signerId
197///
198/// # Arguments
199/// * `manifest_json` - The complete manifest as a serde_json::Value
200///
201/// # Returns
202/// A `ManifestVerification` containing the verified signerId and bundleHash
203pub fn verify_manifest_signature(
204    manifest_json: &serde_json::Value,
205) -> Result<ManifestVerification> {
206    // Extract the signature object
207    let signature_obj = manifest_json
208        .get("signature")
209        .ok_or_else(|| eyre::eyre!("manifest is missing required 'signature' field"))?;
210
211    // Parse the signature fields
212    let algorithm = signature_obj
213        .get("algorithm")
214        .and_then(|v| v.as_str())
215        .ok_or_else(|| eyre::eyre!("signature missing 'algorithm' field"))?;
216
217    // Algorithm comparison is intentionally case-sensitive per specification.
218    // Only "ed25519" (lowercase) is supported. Case variations like "ED25519" or "Ed25519"
219    // are rejected to prevent potential bypasses if other code paths normalize the string.
220    ensure!(
221        algorithm == "ed25519",
222        "unsupported signature algorithm: '{}', expected 'ed25519'",
223        algorithm
224    );
225
226    let public_key_b64 = signature_obj
227        .get("publicKey")
228        .and_then(|v| v.as_str())
229        .ok_or_else(|| eyre::eyre!("signature missing 'publicKey' field"))?;
230
231    let signature_b64 = signature_obj
232        .get("signature")
233        .and_then(|v| v.as_str())
234        .ok_or_else(|| eyre::eyre!("signature missing 'signature' field"))?;
235
236    // Decode the public key and signature from base64url
237    let public_key_bytes = decode_public_key(public_key_b64)?;
238    let signature_bytes = decode_signature(signature_b64)?;
239
240    // Canonicalize the manifest (excluding signature field)
241    let canonical_bytes = canonicalize_manifest(manifest_json)?;
242
243    // Compute the signing payload (SHA-256 of canonical bytes)
244    let signing_payload = compute_signing_payload(&canonical_bytes);
245
246    // Verify the Ed25519 signature
247    verify_ed25519(&signature_bytes, &public_key_bytes, &signing_payload)?;
248
249    // Derive the signerId from the public key
250    let derived_signer_id = derive_signer_id_did_key(&public_key_bytes);
251
252    // Validate that the manifest's signerId matches the derived signerId
253    // signerId is required for signed bundles to ensure identity verification
254    let manifest_signer_id = manifest_json
255        .get("signerId")
256        .and_then(|v| v.as_str())
257        .ok_or_else(|| eyre::eyre!("bundle manifest is missing required signerId field"))?;
258
259    if manifest_signer_id != derived_signer_id {
260        bail!(
261            "signerId mismatch: manifest declares '{}' but signature public key derives to '{}'",
262            manifest_signer_id,
263            derived_signer_id
264        );
265    }
266
267    // Bundle hash equals signing payload in v0
268    let bundle_hash = signing_payload;
269
270    Ok(ManifestVerification {
271        signer_id: derived_signer_id,
272        bundle_hash,
273    })
274}
275
276/// Formats a bundle hash as a lowercase hex string.
277pub fn format_bundle_hash(hash: &[u8; 32]) -> String {
278    hex::encode(hash)
279}
280
281/// Signs a manifest JSON value and adds the signature field.
282///
283/// This function performs the complete signing flow:
284/// 1. Adds signerId to the manifest (if not present)
285/// 2. Canonicalizes the manifest (excluding signature field)
286/// 3. Computes the signing payload (SHA-256)
287/// 4. Signs the payload with Ed25519
288/// 5. Adds the signature object to the manifest
289///
290/// # Arguments
291/// * `manifest_json` - The manifest as a mutable serde_json::Value
292/// * `signing_key` - The Ed25519 signing key
293///
294/// # Returns
295/// The derived signerId (did:key format)
296pub fn sign_manifest_json(
297    manifest_json: &mut serde_json::Value,
298    signing_key: &ed25519_dalek::SigningKey,
299) -> Result<String> {
300    use ed25519_dalek::Signer;
301
302    let verifying_key = signing_key.verifying_key();
303    let signer_id = derive_signer_id_did_key(verifying_key.as_bytes());
304
305    // Always set signerId to match the signing key (overwrites any existing value).
306    // This ensures the signed manifest is always valid and consistent with mero-sign CLI.
307    if let Some(obj) = manifest_json.as_object_mut() {
308        obj.insert(
309            "signerId".to_string(),
310            serde_json::Value::String(signer_id.clone()),
311        );
312    }
313
314    // Canonicalize the manifest (without signature field)
315    let canonical_bytes = canonicalize_manifest(manifest_json)?;
316
317    // Compute the signing payload
318    let signing_payload = compute_signing_payload(&canonical_bytes);
319
320    // Sign the payload
321    let signature = signing_key.sign(&signing_payload);
322
323    // Encode public key and signature as base64url
324    let public_key_b64 = URL_SAFE_NO_PAD.encode(verifying_key.as_bytes());
325    let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
326
327    // Add the signature object to the manifest
328    if let Some(obj) = manifest_json.as_object_mut() {
329        obj.insert(
330            "signature".to_string(),
331            serde_json::json!({
332                "algorithm": "ed25519",
333                "publicKey": public_key_b64,
334                "signature": signature_b64
335            }),
336        );
337    }
338
339    Ok(signer_id)
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use ed25519_dalek::{Signer, SigningKey};
346
347    /// Creates a test manifest JSON with the given values.
348    fn create_test_manifest(
349        package: &str,
350        version: &str,
351        signer_id: Option<&str>,
352    ) -> serde_json::Value {
353        let mut manifest = serde_json::json!({
354            "version": "1.0",
355            "package": package,
356            "appVersion": version,
357            "minRuntimeVersion": "1.0.0",
358            "resources": [
359                {
360                    "role": "executable",
361                    "path": "app.wasm",
362                    "hash": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
363                    "size": 1024
364                }
365            ]
366        });
367
368        if let Some(sid) = signer_id {
369            manifest["signerId"] = serde_json::Value::String(sid.to_string());
370        }
371
372        manifest
373    }
374
375    /// Signs a manifest and returns the complete manifest with signature.
376    fn sign_manifest(manifest: &mut serde_json::Value, signing_key: &SigningKey) -> Result<()> {
377        // Canonicalize the manifest (without signature)
378        let canonical_bytes = canonicalize_manifest(manifest)?;
379
380        // Compute the signing payload
381        let signing_payload = compute_signing_payload(&canonical_bytes);
382
383        // Sign the payload
384        let signature = signing_key.sign(&signing_payload);
385
386        // Encode public key and signature as base64url
387        let public_key_b64 = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().as_bytes());
388        let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
389
390        // Add the signature object to the manifest
391        manifest["signature"] = serde_json::json!({
392            "algorithm": "ed25519",
393            "publicKey": public_key_b64,
394            "signature": signature_b64
395        });
396
397        Ok(())
398    }
399
400    #[test]
401    fn test_derive_signer_id_did_key() {
402        // Test with a known public key
403        let pubkey: [u8; 32] = [
404            0x3b, 0x6a, 0x27, 0xbc, 0xce, 0xb6, 0xa4, 0x2d, 0x62, 0xa3, 0xa8, 0xd0, 0x2a, 0x6f,
405            0x0d, 0x73, 0x65, 0x32, 0x15, 0x77, 0x1d, 0xe2, 0x43, 0xa6, 0x3a, 0xc0, 0x48, 0xa1,
406            0x8b, 0x59, 0xda, 0x29,
407        ];
408
409        let signer_id = derive_signer_id_did_key(&pubkey);
410
411        // Verify the format
412        assert!(signer_id.starts_with("did:key:z"));
413        assert!(signer_id.len() > 10);
414
415        // The same public key should always produce the same signerId
416        let signer_id_2 = derive_signer_id_did_key(&pubkey);
417        assert_eq!(signer_id, signer_id_2);
418    }
419
420    #[test]
421    fn test_canonicalize_manifest_removes_signature() {
422        let manifest = serde_json::json!({
423            "version": "1.0",
424            "package": "com.example.app",
425            "signature": {
426                "algorithm": "ed25519",
427                "publicKey": "test",
428                "signature": "test"
429            }
430        });
431
432        let canonical = canonicalize_manifest(&manifest).unwrap();
433        let canonical_str = String::from_utf8(canonical).unwrap();
434
435        // The canonical form should not contain the signature field
436        assert!(!canonical_str.contains("signature"));
437        // But should contain other fields
438        assert!(canonical_str.contains("package"));
439        assert!(canonical_str.contains("version"));
440    }
441
442    #[test]
443    fn test_canonicalize_manifest_removes_transient_fields() {
444        let manifest = serde_json::json!({
445            "version": "1.0",
446            "package": "com.example.app",
447            "_binary": "some_value",
448            "_overwrite": true
449        });
450
451        let canonical = canonicalize_manifest(&manifest).unwrap();
452        let canonical_str = String::from_utf8(canonical).unwrap();
453
454        // Transient fields should be removed
455        assert!(!canonical_str.contains("_binary"));
456        assert!(!canonical_str.contains("_overwrite"));
457    }
458
459    #[test]
460    fn test_canonicalize_manifest_key_ordering() {
461        // RFC 8785 requires lexicographic key ordering
462        let manifest = serde_json::json!({
463            "z_field": "last",
464            "a_field": "first",
465            "m_field": "middle"
466        });
467
468        let canonical = canonicalize_manifest(&manifest).unwrap();
469        let canonical_str = String::from_utf8(canonical).unwrap();
470
471        // Keys should be in lexicographic order
472        let a_pos = canonical_str.find("a_field").unwrap();
473        let m_pos = canonical_str.find("m_field").unwrap();
474        let z_pos = canonical_str.find("z_field").unwrap();
475
476        assert!(a_pos < m_pos);
477        assert!(m_pos < z_pos);
478    }
479
480    #[test]
481    fn test_compute_bundle_hash() {
482        let canonical_bytes = b"test manifest content";
483        let hash = compute_bundle_hash(canonical_bytes);
484
485        // Hash should be 32 bytes
486        assert_eq!(hash.len(), 32);
487
488        // Same input should produce same hash
489        let hash_2 = compute_bundle_hash(canonical_bytes);
490        assert_eq!(hash, hash_2);
491
492        // Different input should produce different hash
493        let different_bytes = b"different content";
494        let different_hash = compute_bundle_hash(different_bytes);
495        assert_ne!(hash, different_hash);
496    }
497
498    #[test]
499    fn test_decode_public_key_valid() {
500        // Generate a test key
501        let signing_key = SigningKey::generate(&mut rand::thread_rng());
502        let public_key = signing_key.verifying_key();
503
504        // Encode and decode
505        let encoded = URL_SAFE_NO_PAD.encode(public_key.as_bytes());
506        let decoded = decode_public_key(&encoded).unwrap();
507
508        assert_eq!(decoded, *public_key.as_bytes());
509    }
510
511    #[test]
512    fn test_decode_public_key_invalid_length() {
513        let short_key = URL_SAFE_NO_PAD.encode(&[0u8; 16]);
514        let result = decode_public_key(&short_key);
515        assert!(result.is_err());
516        assert!(result
517            .unwrap_err()
518            .to_string()
519            .contains("invalid public key length"));
520    }
521
522    #[test]
523    fn test_decode_signature_valid() {
524        let sig_bytes = [0u8; 64];
525        let encoded = URL_SAFE_NO_PAD.encode(&sig_bytes);
526        let decoded = decode_signature(&encoded).unwrap();
527        assert_eq!(decoded, sig_bytes);
528    }
529
530    #[test]
531    fn test_decode_signature_invalid_length() {
532        let short_sig = URL_SAFE_NO_PAD.encode(&[0u8; 32]);
533        let result = decode_signature(&short_sig);
534        assert!(result.is_err());
535        assert!(result
536            .unwrap_err()
537            .to_string()
538            .contains("invalid signature length"));
539    }
540
541    #[test]
542    fn test_verify_ed25519_valid_signature() {
543        let signing_key = SigningKey::generate(&mut rand::thread_rng());
544        let message = b"test message";
545
546        let signature = signing_key.sign(message);
547
548        let result = verify_ed25519(
549            &signature.to_bytes(),
550            signing_key.verifying_key().as_bytes(),
551            message,
552        );
553
554        assert!(result.is_ok());
555    }
556
557    #[test]
558    fn test_verify_ed25519_invalid_signature() {
559        let signing_key = SigningKey::generate(&mut rand::thread_rng());
560        let message = b"test message";
561        let wrong_message = b"wrong message";
562
563        // Sign one message but verify with another
564        let signature = signing_key.sign(message);
565
566        let result = verify_ed25519(
567            &signature.to_bytes(),
568            signing_key.verifying_key().as_bytes(),
569            wrong_message,
570        );
571
572        assert!(result.is_err());
573    }
574
575    #[test]
576    fn test_verify_manifest_signature_valid() {
577        let signing_key = SigningKey::generate(&mut rand::thread_rng());
578        let signer_id = derive_signer_id_did_key(signing_key.verifying_key().as_bytes());
579
580        let mut manifest = create_test_manifest("com.example.app", "1.0.0", Some(&signer_id));
581
582        sign_manifest(&mut manifest, &signing_key).unwrap();
583
584        let result = verify_manifest_signature(&manifest);
585        assert!(result.is_ok());
586
587        let verification = result.unwrap();
588        assert_eq!(verification.signer_id, signer_id);
589    }
590
591    #[test]
592    fn test_verify_manifest_signature_missing_signature() {
593        let manifest = create_test_manifest("com.example.app", "1.0.0", None);
594
595        let result = verify_manifest_signature(&manifest);
596        assert!(result.is_err());
597        assert!(result
598            .unwrap_err()
599            .to_string()
600            .contains("missing required 'signature' field"));
601    }
602
603    #[test]
604    fn test_verify_manifest_signature_wrong_algorithm() {
605        let signing_key = SigningKey::generate(&mut rand::thread_rng());
606        let signer_id = derive_signer_id_did_key(signing_key.verifying_key().as_bytes());
607        let mut manifest = create_test_manifest("com.example.app", "1.0.0", Some(&signer_id));
608        manifest["signature"] = serde_json::json!({
609            "algorithm": "rsa",
610            "publicKey": "test",
611            "signature": "test"
612        });
613
614        let result = verify_manifest_signature(&manifest);
615        assert!(result.is_err());
616        assert!(result
617            .unwrap_err()
618            .to_string()
619            .contains("unsupported signature algorithm"));
620    }
621
622    #[test]
623    fn test_verify_manifest_signature_missing_signer_id() {
624        let signing_key = SigningKey::generate(&mut rand::thread_rng());
625        // Create manifest without signerId (but with signature)
626        let mut manifest = create_test_manifest("com.example.app", "1.0.0", None);
627        sign_manifest(&mut manifest, &signing_key).unwrap();
628
629        let result = verify_manifest_signature(&manifest);
630        assert!(result.is_err());
631        assert!(result
632            .unwrap_err()
633            .to_string()
634            .contains("missing required signerId field"));
635    }
636
637    #[test]
638    fn test_verify_manifest_signature_signer_id_mismatch() {
639        let signing_key = SigningKey::generate(&mut rand::thread_rng());
640
641        // Use a wrong signerId
642        let mut manifest = create_test_manifest(
643            "com.example.app",
644            "1.0.0",
645            Some("did:key:z6MkWRONGKEY123456789"),
646        );
647
648        sign_manifest(&mut manifest, &signing_key).unwrap();
649
650        let result = verify_manifest_signature(&manifest);
651        assert!(result.is_err());
652        assert!(result
653            .unwrap_err()
654            .to_string()
655            .contains("signerId mismatch"));
656    }
657
658    #[test]
659    fn test_verify_manifest_signature_invalid_signature() {
660        let signing_key = SigningKey::generate(&mut rand::thread_rng());
661        let signer_id = derive_signer_id_did_key(signing_key.verifying_key().as_bytes());
662
663        let mut manifest = create_test_manifest("com.example.app", "1.0.0", Some(&signer_id));
664
665        // Sign with the correct key
666        sign_manifest(&mut manifest, &signing_key).unwrap();
667
668        // Tamper with the manifest after signing
669        manifest["appVersion"] = serde_json::Value::String("2.0.0".to_string());
670
671        let result = verify_manifest_signature(&manifest);
672        assert!(result.is_err());
673        assert!(result
674            .unwrap_err()
675            .to_string()
676            .contains("signature verification failed"));
677    }
678
679    #[test]
680    fn test_signer_id_derivation_is_stable() {
681        // Generate a key and derive signerId multiple times
682        let signing_key = SigningKey::generate(&mut rand::thread_rng());
683        let verifying_key = signing_key.verifying_key();
684        let pubkey = verifying_key.as_bytes();
685
686        let signer_id_1 = derive_signer_id_did_key(pubkey);
687        let signer_id_2 = derive_signer_id_did_key(pubkey);
688        let signer_id_3 = derive_signer_id_did_key(pubkey);
689
690        assert_eq!(signer_id_1, signer_id_2);
691        assert_eq!(signer_id_2, signer_id_3);
692    }
693
694    #[test]
695    fn test_bundle_hash_is_deterministic() {
696        let signing_key = SigningKey::generate(&mut rand::thread_rng());
697        let signer_id = derive_signer_id_did_key(signing_key.verifying_key().as_bytes());
698
699        let manifest = create_test_manifest("com.example.app", "1.0.0", Some(&signer_id));
700
701        // Compute hash multiple times
702        let canonical_1 = canonicalize_manifest(&manifest).unwrap();
703        let hash_1 = compute_bundle_hash(&canonical_1);
704
705        let canonical_2 = canonicalize_manifest(&manifest).unwrap();
706        let hash_2 = compute_bundle_hash(&canonical_2);
707
708        assert_eq!(hash_1, hash_2);
709        assert_eq!(canonical_1, canonical_2);
710    }
711
712    #[test]
713    fn test_format_bundle_hash() {
714        let hash: [u8; 32] = [
715            0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6, 0xa7, 0xb8, 0xc9, 0xd0, 0xe1, 0xf2, 0xa3, 0xb4,
716            0xc5, 0xd6, 0xe7, 0xf8, 0xa9, 0xb0, 0xc1, 0xd2, 0xe3, 0xf4, 0xa5, 0xb6, 0xc7, 0xd8,
717            0xe9, 0xf0, 0xa1, 0xb2,
718        ];
719
720        let formatted = format_bundle_hash(&hash);
721
722        // Should be lowercase hex
723        assert_eq!(formatted.len(), 64);
724        assert!(formatted.chars().all(|c| c.is_ascii_hexdigit()));
725        assert!(formatted.chars().all(|c| !c.is_uppercase()));
726    }
727}