Skip to main content

krabka_remote_storage/worm/manifest/
signing.rs

1//! The Ed25519 attestation over a manifest's chain head.
2//!
3//! `manifest_signing_bytes` fixes what a signature covers, the writer signs
4//! it, and `verify_manifest_signature` recomputes the head and checks the
5//! claim. The signature is a sibling of the body, never a member of it.
6
7use {
8    super::{
9        ChainHead, EpochId, HexBytes, ManifestSeq, SegmentManifest, encoding::push_bytes,
10        manifest_head,
11    },
12    krabka_audit::signing::verify_signature,
13    serde::{Deserialize, Serialize},
14};
15
16/// Domain separation for the manifest signature. Distinct from
17/// [`krabka_audit::signing::CHECKPOINT_DOMAIN`] — never share a domain
18/// across signature purposes.
19pub const MANIFEST_DOMAIN: &[u8] = b"krabka-worm-manifest-v1\0";
20
21/// Ed25519 signature over one manifest's chain head.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct ManifestSignature {
25    /// Identifier of the key that produced the signature.
26    pub key_id: String,
27    /// Raw Ed25519 public key of that key.
28    pub public_key: HexBytes,
29    /// Signature over [`manifest_signing_bytes`].
30    pub signature: HexBytes,
31}
32
33/// Canonical signed payload for a manifest.
34///
35/// The layout is
36/// `MANIFEST_DOMAIN ‖ kid_len(u64 BE) ‖ kid ‖ epoch_id(16) ‖ seq(u64 BE) ‖ head(32)`.
37/// The writer calls it to sign and the verifier calls it to verify.
38#[must_use]
39pub fn manifest_signing_bytes(
40    key_id: &str,
41    epoch_id: EpochId,
42    seq: ManifestSeq,
43    head: ChainHead,
44) -> Vec<u8> {
45    let kid = key_id.as_bytes();
46    let mut out = Vec::with_capacity(MANIFEST_DOMAIN.len() + 8 + kid.len() + 16 + 8 + 32);
47    out.extend_from_slice(MANIFEST_DOMAIN);
48    push_bytes(&mut out, kid);
49    out.extend_from_slice(epoch_id.0.as_bytes());
50    out.extend_from_slice(&seq.0.to_be_bytes());
51    out.extend_from_slice(&head.0);
52    out
53}
54
55/// Recomputes the head from the body, then checks the signature against
56/// `public_key`.
57///
58/// Returns `false` for an unsigned manifest. A caller that treats "unsigned"
59/// and "bad signature" differently must test `manifest.signature.is_none()`
60/// first.
61#[must_use]
62pub fn verify_manifest_signature(manifest: &SegmentManifest, public_key: &[u8]) -> bool {
63    let Some(signature) = manifest.signature.as_ref() else {
64        return false;
65    };
66    let head = manifest_head(&manifest.body);
67    let message = manifest_signing_bytes(
68        &signature.key_id,
69        manifest.body.chain.epoch_id,
70        manifest.body.chain.seq,
71        head,
72    );
73    verify_signature(public_key, &message, &signature.signature.0)
74}
75
76#[cfg(test)]
77mod tests {
78    use {
79        super::*,
80        crate::worm::manifest::{
81            MANIFEST_BODY_DOMAIN, ManifestBody, Sha256Digest, canonical_manifest_bytes,
82            test_support::{KEY_ID, sample_body},
83        },
84        assert2::check,
85        krabka_audit::{
86            ids::{EpochMs, Seq},
87            signing::{CHECKPOINT_DOMAIN, FileEd25519Signer, checkpoint_signing_bytes},
88        },
89        ring::{rand::SystemRandom, signature::Ed25519KeyPair},
90        uuid::Uuid,
91    };
92
93    struct FreshSigner {
94        signer: FileEd25519Signer,
95        public_key: Vec<u8>,
96    }
97
98    fn fresh_signer(key_id: &str) -> FreshSigner {
99        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
100        let signer =
101            FileEd25519Signer::from_pkcs8_bytes(pkcs8.as_ref(), key_id.to_owned()).unwrap();
102        let public_key = signer.public_key();
103        FreshSigner { signer, public_key }
104    }
105
106    fn sign_manifest(body: ManifestBody, signer: &FileEd25519Signer) -> SegmentManifest {
107        let head = manifest_head(&body);
108        let message =
109            manifest_signing_bytes(signer.key_id(), body.chain.epoch_id, body.chain.seq, head);
110        SegmentManifest {
111            signature: Some(ManifestSignature {
112                key_id: signer.key_id().to_owned(),
113                public_key: HexBytes(signer.public_key()),
114                signature: HexBytes(signer.sign(&message)),
115            }),
116            body,
117        }
118    }
119
120    #[derive(Clone, Copy)]
121    enum SignatureCheck {
122        Authentic,
123        Body,
124        Sequence,
125        AnotherHead,
126        KeyId,
127        Unsigned,
128    }
129
130    #[yare::parameterized(
131        authentic = { SignatureCheck::Authentic },
132        tampered_body = { SignatureCheck::Body },
133        tampered_sequence = { SignatureCheck::Sequence },
134        signature_for_another_head = { SignatureCheck::AnotherHead },
135        tampered_key_id = { SignatureCheck::KeyId },
136        unsigned = { SignatureCheck::Unsigned },
137    )]
138    fn manifest_signature_round_trips_and_rejects_tampering(case: SignatureCheck) {
139        let FreshSigner { signer, public_key } = fresh_signer(KEY_ID);
140        let base = sample_body();
141        let authentic = sign_manifest(base.clone(), &signer);
142
143        let json = serde_json::to_string(&authentic).unwrap();
144        let reparsed: SegmentManifest = serde_json::from_str(&json).unwrap();
145        check!(reparsed == authentic, "signature survives JSON");
146
147        let mut tampered_body = authentic.clone();
148        tampered_body.body.objects[0].sha256 = Sha256Digest::of(b"swapped body");
149
150        let mut tampered_seq = authentic.clone();
151        tampered_seq.body.chain.seq = ManifestSeq(base.chain.seq.0 + 1);
152
153        // A signature made over a head that this body does not produce.
154        let wrong_head_message = manifest_signing_bytes(
155            KEY_ID,
156            base.chain.epoch_id,
157            base.chain.seq,
158            ChainHead([0xab; 32]),
159        );
160        let tampered_head = SegmentManifest {
161            body: base.clone(),
162            signature: Some(ManifestSignature {
163                key_id: KEY_ID.to_owned(),
164                public_key: HexBytes(public_key.clone()),
165                signature: HexBytes(signer.sign(&wrong_head_message)),
166            }),
167        };
168
169        let mut tampered_key_id = authentic.clone();
170        tampered_key_id.signature.as_mut().unwrap().key_id = "worm-key-2".to_owned();
171
172        let unsigned = SegmentManifest {
173            body: base,
174            signature: None,
175        };
176
177        let (manifest, expected) = match case {
178            SignatureCheck::Authentic => (&reparsed, true),
179            SignatureCheck::Body => (&tampered_body, false),
180            SignatureCheck::Sequence => (&tampered_seq, false),
181            SignatureCheck::AnotherHead => (&tampered_head, false),
182            SignatureCheck::KeyId => (&tampered_key_id, false),
183            SignatureCheck::Unsigned => (&unsigned, false),
184        };
185        check!(verify_manifest_signature(manifest, &public_key) == expected);
186
187        let FreshSigner {
188            public_key: other_public_key,
189            ..
190        } = fresh_signer("worm-key-2");
191        check!(!verify_manifest_signature(&authentic, &other_public_key));
192    }
193
194    /// Pins the exact bytes an Ed25519 signature covers.
195    ///
196    /// This layout is a wire contract. An auditor writing an independent
197    /// verifier reads the rustdoc on [`manifest_signing_bytes`] and reproduces
198    /// it, so the doc being wrong is worse than a slow encoder. Nothing else
199    /// here fixes these bytes: every other row goes through sign-then-verify,
200    /// which agrees with itself whatever the layout happens to be, so a drift
201    /// between the documented layout and the encoder passes unnoticed. That is
202    /// precisely what happened when the length prefix widened from `u32` to
203    /// `u64` and the rustdoc kept saying `u32`.
204    ///
205    /// The expectation is assembled from its parts rather than from the
206    /// function under test, so this asserts against the documented layout and
207    /// not against whatever the encoder currently emits.
208    #[test]
209    fn manifest_signing_bytes_match_the_documented_layout() {
210        let key_id = "worm-key-1";
211        let epoch_id = EpochId(Uuid::from_u128(0x99));
212        let seq = ManifestSeq(7);
213        let head = ChainHead([0xab; 32]);
214
215        let mut expected = Vec::new();
216        expected.extend_from_slice(MANIFEST_DOMAIN);
217        expected.extend_from_slice(&(key_id.len() as u64).to_be_bytes());
218        expected.extend_from_slice(key_id.as_bytes());
219        expected.extend_from_slice(epoch_id.0.as_bytes());
220        expected.extend_from_slice(&seq.0.to_be_bytes());
221        expected.extend_from_slice(&head.0);
222
223        check!(manifest_signing_bytes(key_id, epoch_id, seq, head) == expected);
224        // The widths the doc names, restated as arithmetic so a change to any
225        // one of them fails here and not only in the byte comparison above.
226        check!(expected.len() == MANIFEST_DOMAIN.len() + 8 + key_id.len() + 16 + 8 + 32);
227    }
228
229    #[test]
230    fn manifest_domain_differs_from_audit_checkpoint_domain() {
231        check!(MANIFEST_DOMAIN != CHECKPOINT_DOMAIN);
232        check!(MANIFEST_BODY_DOMAIN != CHECKPOINT_DOMAIN);
233        check!(MANIFEST_DOMAIN != MANIFEST_BODY_DOMAIN);
234
235        let body = sample_body();
236        check!(canonical_manifest_bytes(&body).starts_with(MANIFEST_BODY_DOMAIN));
237        let head = manifest_head(&body);
238        let manifest_bytes =
239            manifest_signing_bytes(KEY_ID, body.chain.epoch_id, body.chain.seq, head);
240        check!(manifest_bytes.starts_with(MANIFEST_DOMAIN));
241        check!(!manifest_bytes.starts_with(CHECKPOINT_DOMAIN));
242
243        // Behaviourally: an audit checkpoint signature over this very head, by
244        // this very key, is not a manifest signature.
245        let FreshSigner { signer, public_key } = fresh_signer(KEY_ID);
246        let checkpoint_bytes =
247            checkpoint_signing_bytes(KEY_ID, Seq(body.chain.seq.0), &head.0, EpochMs(0));
248        let cross_purpose = SegmentManifest {
249            body,
250            signature: Some(ManifestSignature {
251                key_id: KEY_ID.to_owned(),
252                public_key: HexBytes(public_key.clone()),
253                signature: HexBytes(signer.sign(&checkpoint_bytes)),
254            }),
255        };
256        check!(!verify_manifest_signature(&cross_purpose, &public_key));
257    }
258}