1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
// Copyright 2023 Developers of the reconcile project.
//
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
// https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms.
//! Per-datagram authentication/framing. The `reconcile` facade requires either a cluster key or an explicit insecure opt-in.
//!
//! # Wire layout
//!
//! Disabled: `version (1 B) || protocol_messages`
//!
//! MAC mode: `tag (32 B) || seq (8 B LE) || stamp (8 B LE) || version (1 B) || protocol_messages`
//!
//! Encryption mode: `nonce (24 B) || encrypt(seq (8 B LE) || stamp (8 B LE) || version (1 B) || protocol_messages) || tag (16 B)`
//!
//! The version byte sits **after** the replay header, not before it: `decode_replay_header`
//! parses a fixed 16-byte prefix, so putting anything ahead of it would need every existing
//! authenticated-mode offset recomputed. It always ends up as the first byte `Payload::bytes`
//! exposes, which is what lets [`Payload::check_version`] treat all three modes uniformly.
//!
//! The replay header sits inside the authenticated/encrypted region in both cases; the wire
//! version byte sits inside it too, and — unlike the replay header, which is absent when
//! disabled — is present on **every** datagram regardless of authentication mode: a
//! mixed-version cluster must be diagnosable whether or not a cluster key is configured.
//!
//! The layering carries the security invariants in the types: [`Authenticator`] is the sole
//! producer of a [`Payload`], and message handling consumes `Payload<`[`Verified`]`>`, obtainable
//! only from [`Payload::verify_replay`] — so authenticate-before-decode
//! (`ARCHITECTURE.md` §5 invariant 5) and check-replay-before-handle are both compile-time.
//! [`Payload::check_version`] is the mandatory step between the two: version-checking runs on
//! authenticated bytes (so a forged version claim is rejected the same way a forged payload is),
//! but ahead of replay bookkeeping (so a differently-versioned peer never consumes a replay-filter
//! slot over a datagram this build cannot even interpret).
//!
//! Split across siblings by concern: `mac` owns the crate-private `Mac` backend trait, its two
//! `mac-*`-feature-gated implementations, and the `Tag` they produce; `key` owns
//! [`ClusterKey`]/[`ClusterKeyError`]/[`Keys`] construction and [`Authenticator`]'s own
//! construction and size accounting; `seal` and `open` each own one direction of the wire
//! (encrypt+authenticate on send, verify+decrypt on receive); `encryption` (feature `encryption`)
//! owns the XChaCha20-Poly1305 AEAD primitive both directions call into. This file keeps the
//! public type definitions (their module location is their `cargo public-api`-visible path — see
//! AGENTS.md §11) plus the module doc above every sibling shares.
use Cow;
use PhantomData;
use crate;
/// Length in bytes of the authentication tag prepended to every datagram.
pub const TAG_LEN: usize = 32;
/// Length in bytes of a cluster key.
pub const KEY_LEN: usize = 32;
/// Length in bytes of the wire-version byte prepended to every datagram's protected region,
/// regardless of authentication mode.
pub const VERSION_LEN: usize = 1;
/// The wire protocol version this build produces and accepts.
///
/// Bumping it is the sanctioned way to make a non-additive change to the `Message` wire format:
/// a peer running a different version is rejected with a distinguishable, counted reason
/// (`reconcile_datagrams_dropped_total{reason="version"}`) rather than silently misread or
/// indistinguishably dropped as malformed. There is currently no accepted-version *window* — a
/// mismatch of any kind is rejected; widening that is a policy change to make deliberately, not a
/// side effect of the next bump.
pub const WIRE_VERSION: u8 = 3;
/// Length in bytes of the XChaCha20-Poly1305 nonce prepended to each encrypted datagram.
///
/// 192 bits: safe to draw at random per datagram, so no per-peer counter is needed.
pub const AEAD_NONCE_LEN: usize = 24;
/// Length in bytes of the XChaCha20-Poly1305 (Poly1305) authentication tag.
pub const AEAD_TAG_LEN: usize = 16;
compile_error!;
/// A shared cluster secret. Constructing one is the only way to enable authentication.
///
/// `Clone` but not `Copy`: the `zeroize` feature gives it a wiping `Drop`, which `Copy` forbids.
/// The public boundary (`Config::cluster_key`, `Authenticator::new`) takes and returns
/// `ClusterKey`, never a bare `[u8; 32]` — AGENTS.md §4: type-owned parsing, an invalid instance
/// structurally impossible to hand to either.
///
/// `Debug` is redacting: it never prints the key material, so an accidental `{:?}` in a log
/// statement cannot leak it.
///
/// ```
/// use reconcile_gossip::auth::ClusterKey;
///
/// let key = ClusterKey::from_hex(
/// "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
/// )
/// .unwrap();
///
/// // Debug never prints the key material, even by accident.
/// assert_eq!(format!("{key:?}"), "ClusterKey(\"<redacted>\")");
///
/// // A key that isn't exactly 64 hex characters is rejected, not silently truncated/padded.
/// assert!(ClusterKey::from_hex("too short").is_err());
/// ```
;
/// Why constructing a [`ClusterKey`] from untrusted input failed.
///
/// ```
/// use reconcile_gossip::auth::{ClusterKey, ClusterKeyError};
///
/// let err = ClusterKey::from_hex("too short").unwrap_err();
/// assert_eq!(err, ClusterKeyError::WrongHexLength(9));
/// assert_eq!(
/// err.to_string(),
/// "cluster key must be 64 hex characters, got 9"
/// );
/// ```
/// Returned by [`Authenticator::new`]/[`with_rotation`](Authenticator::with_rotation) when
/// `encrypt` is requested but the crate was built without the `encryption` feature.
;
/// [`Payload`] state: cleared the MAC/AEAD gate, but not yet checked against the per-peer replay
/// filter. The only state [`Authenticator::open`] can produce.
;
/// [`Payload`] state: cleared the replay filter too (or exempt because the connection runs
/// unauthenticated). The only state [`Payload::as_bytes`] accepts, and the only state message
/// handling (`handle_messages`) accepts — obtainable solely through
/// [`Payload::verify_replay`].
;
/// A datagram payload that has cleared the authentication gate, obtainable only from
/// [`Authenticator::open`].
///
/// `State` is [`Authenticated`] or [`Verified`], so the order of the two checks is a compile-time
/// property. `seq`/`stamp` are [`Seq::NONE`]/[`Stamp::NONE`] in unauthenticated mode. [`Cow`]
/// because the MAC path borrows the receive buffer and the encrypted path owns a plaintext.
/// A [`ClusterKey`] this node seals outgoing datagrams with, plus zero or more additional keys it
/// still accepts on the verify path — the shape a rotation needs: roll out `also_accept:
/// [old_key]` cluster-wide, then once every peer has it, roll `primary` to the new key with the
/// old one demoted to `also_accept`, then finally drop it once every peer is on the new primary.
/// Authentication policy and datagram framing for one node: the sole producer of [`Payload`].