Skip to main content

krabka_audit/
signing.rs

1//! Ed25519 checkpoint signing for the audit hash-chain.
2//!
3//! `checkpoint_signing_bytes` defines the canonical signed payload. The writer
4//! calls it to sign, and the verifier calls it to verify. Never reimplement it.
5
6use {
7    crate::{
8        ids::{EpochMs, Seq},
9        sink::AuditError,
10    },
11    krabka_protocol::krabka::freeze::DescribedTopicFreeze,
12    ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey},
13    std::{fs, path::Path},
14};
15
16/// The freeze fields shared by the broker and operator CLI. Both sides encode
17/// these fields independently into the signed wire payload.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct FreezeSigningInput<'a> {
20    pub cluster_id: &'a str,
21    pub pattern_type: i8,
22    pub scope: &'a str,
23    pub frozen: bool,
24    pub reason: &'a str,
25    pub set_by: &'a str,
26    pub set_at_ms: i64,
27    pub proposal_id: [u8; 16],
28}
29
30/// Project the signed values from a described freeze returned by the broker.
31#[must_use]
32#[bon::builder]
33pub fn freeze_signing_input<'a>(
34    cluster_id: &'a str,
35    entry: &'a DescribedTopicFreeze,
36    frozen: bool,
37) -> FreezeSigningInput<'a> {
38    FreezeSigningInput {
39        cluster_id,
40        pattern_type: entry.pattern_type,
41        scope: &entry.scope,
42        frozen,
43        reason: &entry.reason,
44        set_by: &entry.set_by,
45        set_at_ms: entry.set_at_ms,
46        proposal_id: entry.proposal_id.0,
47    }
48}
49
50/// Versioned domain-separation prefix for checkpoint signatures.
51pub const CHECKPOINT_DOMAIN: &[u8] = b"krabka-audit-ckpt-v1\0";
52
53/// File-backed Ed25519 signer that reads a PKCS#8 v2 DER key.
54#[derive(derive_more::Debug)]
55pub struct FileEd25519Signer {
56    key_id: String,
57    #[debug(skip)]
58    key_pair: Ed25519KeyPair,
59    #[debug(skip)]
60    public_key: Vec<u8>,
61}
62
63impl FileEd25519Signer {
64    /// Load a PKCS#8 Ed25519 key from `path`.
65    #[tracing::instrument(level = "debug", skip_all, fields(key_id = %key_id), err)]
66    /// # Errors
67    /// Returns an error if the file at `path` cannot be read. Returns an error
68    /// if the file does not hold a valid PKCS#8 Ed25519 key.
69    pub fn from_pkcs8_file(path: impl AsRef<Path>, key_id: String) -> Result<Self, AuditError> {
70        let der =
71            fs::read(path.as_ref()).map_err(|e| AuditError::Key(format!("read key file: {e}")))?;
72        Self::from_pkcs8_bytes(&der, key_id)
73    }
74
75    /// Load a PKCS#8 Ed25519 key from DER bytes.
76    #[tracing::instrument(level = "debug", skip_all, fields(key_id = %key_id, bytes = der.len()), err)]
77    /// # Errors
78    /// Returns an error if `der` is not a valid PKCS#8 Ed25519 key.
79    pub fn from_pkcs8_bytes(der: &[u8], key_id: String) -> Result<Self, AuditError> {
80        let key_pair = Ed25519KeyPair::from_pkcs8(der)
81            .map_err(|_| AuditError::Key("invalid PKCS#8 Ed25519 key".to_owned()))?;
82        let public_key = key_pair.public_key().as_ref().to_vec();
83        Ok(Self {
84            key_id,
85            key_pair,
86            public_key,
87        })
88    }
89}
90
91impl FileEd25519Signer {
92    /// Stable identifier for the key. Every checkpoint records it, so chains
93    /// span key-rotation epochs verifiably.
94    #[must_use]
95    pub fn key_id(&self) -> &str { &self.key_id }
96
97    /// Ed25519 signature over `msg`.
98    #[must_use]
99    pub fn sign(&self, msg: &[u8]) -> Vec<u8> { self.key_pair.sign(msg).as_ref().to_vec() }
100
101    /// Raw 32-byte Ed25519 public key.
102    #[must_use]
103    pub fn public_key(&self) -> Vec<u8> { self.public_key.clone() }
104}
105
106/// Canonical checkpoint signed payload.
107///
108/// The layout is
109/// `DOMAIN ‖ key_id_len(u16 BE) ‖ key_id ‖ seq_high(u64 BE) ‖ head(32) ‖ time_ms(i64 BE)`.
110#[must_use]
111pub fn checkpoint_signing_bytes(
112    key_id: &str,
113    seq_high: Seq,
114    head: &[u8; 32],
115    time_ms: EpochMs,
116) -> Vec<u8> {
117    let kid = key_id.as_bytes();
118    let kid_len = u16::try_from(kid.len()).unwrap_or(u16::MAX);
119    let mut v = Vec::with_capacity(CHECKPOINT_DOMAIN.len() + 2 + kid.len() + 8 + 32 + 8);
120    v.extend_from_slice(CHECKPOINT_DOMAIN);
121    v.extend_from_slice(&kid_len.to_be_bytes());
122    v.extend_from_slice(kid);
123    v.extend_from_slice(&seq_high.0.to_be_bytes());
124    v.extend_from_slice(head);
125    v.extend_from_slice(&time_ms.0.to_be_bytes());
126    v
127}
128
129/// Verify an Ed25519 signature.
130///
131/// Returns `false` on any error.
132#[must_use]
133pub fn verify_signature(public_key: &[u8], msg: &[u8], sig: &[u8]) -> bool {
134    UnparsedPublicKey::new(&ED25519, public_key)
135        .verify(msg, sig)
136        .is_ok()
137}
138
139#[cfg(test)]
140mod tests {
141    use {
142        super::*,
143        crate::{
144            sink::AuditError,
145            test_support::{TestSigner, signer_with_key_id},
146        },
147        assert2::check,
148    };
149
150    #[yare::parameterized(
151        valid_signature = { false, false, true },
152        tampered_message = { false, true, false },
153        wrong_key = { true, false, false },
154    )]
155    fn sign_then_verify_roundtrips(use_other_key: bool, tamper_message: bool, expected: bool) {
156        let TestSigner {
157            signer,
158            public_key: pubkey,
159        } = signer_with_key_id("k1");
160        check!((signer.key_id(), signer.public_key()) == ("k1", pubkey.clone()));
161        let msg = b"the quick brown fox";
162        let sig = signer.sign(msg);
163        let TestSigner {
164            public_key: other_pub,
165            ..
166        } = signer_with_key_id("k2");
167        let key = if use_other_key { &other_pub } else { &pubkey };
168        let message = if tamper_message {
169            b"the quick brown FOX".as_slice()
170        } else {
171            msg.as_slice()
172        };
173        check!(verify_signature(key, message, &sig) == expected);
174    }
175
176    #[yare::parameterized(
177        identical_fields = { "k1", Seq(42), [9u8; 32], EpochMs(1_700_000_000_000), true },
178        different_key = { "k2", Seq(42), [9u8; 32], EpochMs(1_700_000_000_000), false },
179        different_sequence = { "k1", Seq(43), [9u8; 32], EpochMs(1_700_000_000_000), false },
180        different_head = { "k1", Seq(42), [8u8; 32], EpochMs(1_700_000_000_000), false },
181        different_time = { "k1", Seq(42), [9u8; 32], EpochMs(1), false },
182    )]
183    fn checkpoint_bytes_are_canonical_and_field_sensitive(
184        key_id: &str,
185        seq: Seq,
186        candidate_head: [u8; 32],
187        time: EpochMs,
188        expected_equal: bool,
189    ) {
190        let base = checkpoint_signing_bytes("k1", Seq(42), &[9u8; 32], EpochMs(1_700_000_000_000));
191        check!(base.starts_with(CHECKPOINT_DOMAIN));
192        check!(
193            (checkpoint_signing_bytes(key_id, seq, &candidate_head, time) == base)
194                == expected_equal
195        );
196    }
197
198    #[test]
199    fn bad_pkcs8_is_a_key_error() {
200        let err = FileEd25519Signer::from_pkcs8_bytes(b"not-a-key", "k".into());
201        check!(matches!(err, Err(AuditError::Key(_))));
202    }
203
204    #[test]
205    fn freeze_input_preserves_every_signed_field() {
206        use krabka_protocol::{krabka::freeze::DescribedTopicFreeze, primitives::uuid::Uuid};
207
208        let entry = DescribedTopicFreeze {
209            pattern_type: 4,
210            scope: "tenant-a.".to_owned(),
211            reason: "maintenance".to_owned(),
212            set_by: "User:alice".to_owned(),
213            set_at_ms: 0x0102_0304_0506_0708,
214            proposal_id: Uuid([7; 16]),
215            ..DescribedTopicFreeze::default()
216        };
217
218        for frozen in [true, false] {
219            check!(
220                freeze_signing_input()
221                    .cluster_id("cluster-a")
222                    .entry(&entry)
223                    .frozen(frozen)
224                    .call()
225                    == FreezeSigningInput {
226                        cluster_id: "cluster-a",
227                        pattern_type: 4,
228                        scope: "tenant-a.",
229                        frozen,
230                        reason: "maintenance",
231                        set_by: "User:alice",
232                        set_at_ms: 0x0102_0304_0506_0708,
233                        proposal_id: [7; 16],
234                    }
235            );
236        }
237    }
238}