Skip to main content

affinidi_encoding/
multicodec.rs

1//! Multicodec encoding/decoding
2//!
3//! Multicodec is a self-describing format that prefixes data with a varint
4//! indicating the type of data that follows.
5//!
6//! See: <https://github.com/multiformats/multicodec>
7
8use crate::EncodingError;
9use serde::{Deserialize, Serialize};
10use zeroize::{Zeroize, ZeroizeOnDrop};
11
12// ****************************************************************************
13// Codec Magic Numbers
14// See: https://github.com/multiformats/multicodec/blob/master/table.csv
15// ****************************************************************************
16pub const ED25519_PUB: u64 = 0xed;
17pub const ED25519_PRIV: u64 = 0x1300;
18pub const X25519_PUB: u64 = 0xec;
19pub const X25519_PRIV: u64 = 0x1302;
20pub const SECP256K1_PUB: u64 = 0xe7;
21pub const SECP256K1_PRIV: u64 = 0x1301;
22pub const P256_PUB: u64 = 0x1200;
23pub const P256_PRIV: u64 = 0x1306;
24pub const P384_PUB: u64 = 0x1201;
25pub const P384_PRIV: u64 = 0x1307;
26pub const P521_PUB: u64 = 0x1202;
27pub const P521_PRIV: u64 = 0x1308;
28// BLS12-381 public keys (used by BBS+ issuers). Compressed group elements:
29// G1 = 48 bytes, G2 = 96 bytes. A BBS issuer's verification key is a G2 point.
30pub const BLS12381_G1_PUB: u64 = 0xea;
31pub const BLS12381_G2_PUB: u64 = 0xeb;
32
33// Post-quantum codecs — draft entries from the official multicodec table.
34// We store ML-DSA private keys as the 32-byte seed, so we use the
35// `-priv-seed` codes (0x131a–0x131c), not the 2560/4032/4896-byte
36// expanded-private codes (0x1317–0x1319).
37//
38// SLH-DSA has no private-key codec registered; `Secret::from_multibase`
39// and `get_private_keymultibase` return an error for SLH-DSA keys.
40//
41// These seven are `draft` upstream and may legitimately move, so they are
42// pinned by `pqc_code_points_match_the_multicodec_registry` rather than
43// trusted. Last verified 2026-09-15 against `table.csv` at commit
44// 38e3bf3e38f613679d76ef2041d73a8060f8622a — change a value here and
45// re-state the revision there.
46pub const ML_DSA_44_PUB: u64 = 0x1210;
47pub const ML_DSA_44_PRIV_SEED: u64 = 0x131a;
48pub const ML_DSA_65_PUB: u64 = 0x1211;
49pub const ML_DSA_65_PRIV_SEED: u64 = 0x131b;
50pub const ML_DSA_87_PUB: u64 = 0x1212;
51pub const ML_DSA_87_PRIV_SEED: u64 = 0x131c;
52pub const SLH_DSA_SHA2_128S_PUB: u64 = 0x1220;
53
54/// Known codec types
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56pub enum Codec {
57    Ed25519Pub,
58    Ed25519Priv,
59    X25519Pub,
60    X25519Priv,
61    Secp256k1Pub,
62    Secp256k1Priv,
63    P256Pub,
64    P256Priv,
65    P384Pub,
66    P384Priv,
67    P521Pub,
68    P521Priv,
69    Bls12381G1Pub,
70    Bls12381G2Pub,
71    MlDsa44Pub,
72    MlDsa44PrivSeed,
73    MlDsa65Pub,
74    MlDsa65PrivSeed,
75    MlDsa87Pub,
76    MlDsa87PrivSeed,
77    SlhDsaSha2_128sPub,
78    Unknown(u64),
79}
80
81impl Codec {
82    /// Convert a raw codec value to a Codec enum
83    pub fn from_u64(value: u64) -> Self {
84        match value {
85            ED25519_PUB => Codec::Ed25519Pub,
86            ED25519_PRIV => Codec::Ed25519Priv,
87            X25519_PUB => Codec::X25519Pub,
88            X25519_PRIV => Codec::X25519Priv,
89            SECP256K1_PUB => Codec::Secp256k1Pub,
90            SECP256K1_PRIV => Codec::Secp256k1Priv,
91            P256_PUB => Codec::P256Pub,
92            P256_PRIV => Codec::P256Priv,
93            P384_PUB => Codec::P384Pub,
94            P384_PRIV => Codec::P384Priv,
95            P521_PUB => Codec::P521Pub,
96            P521_PRIV => Codec::P521Priv,
97            BLS12381_G1_PUB => Codec::Bls12381G1Pub,
98            BLS12381_G2_PUB => Codec::Bls12381G2Pub,
99            ML_DSA_44_PUB => Codec::MlDsa44Pub,
100            ML_DSA_44_PRIV_SEED => Codec::MlDsa44PrivSeed,
101            ML_DSA_65_PUB => Codec::MlDsa65Pub,
102            ML_DSA_65_PRIV_SEED => Codec::MlDsa65PrivSeed,
103            ML_DSA_87_PUB => Codec::MlDsa87Pub,
104            ML_DSA_87_PRIV_SEED => Codec::MlDsa87PrivSeed,
105            SLH_DSA_SHA2_128S_PUB => Codec::SlhDsaSha2_128sPub,
106            other => Codec::Unknown(other),
107        }
108    }
109
110    /// Convert to raw u64 value
111    pub fn to_u64(self) -> u64 {
112        match self {
113            Codec::Ed25519Pub => ED25519_PUB,
114            Codec::Ed25519Priv => ED25519_PRIV,
115            Codec::X25519Pub => X25519_PUB,
116            Codec::X25519Priv => X25519_PRIV,
117            Codec::Secp256k1Pub => SECP256K1_PUB,
118            Codec::Secp256k1Priv => SECP256K1_PRIV,
119            Codec::P256Pub => P256_PUB,
120            Codec::P256Priv => P256_PRIV,
121            Codec::P384Pub => P384_PUB,
122            Codec::P384Priv => P384_PRIV,
123            Codec::P521Pub => P521_PUB,
124            Codec::P521Priv => P521_PRIV,
125            Codec::Bls12381G1Pub => BLS12381_G1_PUB,
126            Codec::Bls12381G2Pub => BLS12381_G2_PUB,
127            Codec::MlDsa44Pub => ML_DSA_44_PUB,
128            Codec::MlDsa44PrivSeed => ML_DSA_44_PRIV_SEED,
129            Codec::MlDsa65Pub => ML_DSA_65_PUB,
130            Codec::MlDsa65PrivSeed => ML_DSA_65_PRIV_SEED,
131            Codec::MlDsa87Pub => ML_DSA_87_PUB,
132            Codec::MlDsa87PrivSeed => ML_DSA_87_PRIV_SEED,
133            Codec::SlhDsaSha2_128sPub => SLH_DSA_SHA2_128S_PUB,
134            Codec::Unknown(v) => v,
135        }
136    }
137
138    /// Returns true if this is a public key codec
139    pub fn is_public(&self) -> bool {
140        matches!(
141            self,
142            Codec::Ed25519Pub
143                | Codec::X25519Pub
144                | Codec::Secp256k1Pub
145                | Codec::P256Pub
146                | Codec::P384Pub
147                | Codec::P521Pub
148                | Codec::Bls12381G1Pub
149                | Codec::Bls12381G2Pub
150                | Codec::MlDsa44Pub
151                | Codec::MlDsa65Pub
152                | Codec::MlDsa87Pub
153                | Codec::SlhDsaSha2_128sPub // SLH-DSA has no private codec registered
154        )
155    }
156
157    /// Returns the expected key length for this codec, if known
158    pub fn expected_key_length(&self) -> Option<usize> {
159        match self {
160            Codec::Ed25519Pub | Codec::Ed25519Priv => Some(32),
161            Codec::X25519Pub | Codec::X25519Priv => Some(32),
162            Codec::Secp256k1Pub => Some(33), // compressed
163            Codec::P256Pub => Some(33),      // compressed
164            Codec::P384Pub => Some(49),      // compressed
165            Codec::P521Pub => Some(67),      // compressed
166            // BLS12-381 compressed group elements.
167            Codec::Bls12381G1Pub => Some(48),
168            Codec::Bls12381G2Pub => Some(96),
169            // ML-DSA public keys: FIPS 204 fixed sizes
170            Codec::MlDsa44Pub => Some(1312),
171            Codec::MlDsa65Pub => Some(1952),
172            Codec::MlDsa87Pub => Some(2592),
173            // ML-DSA priv-seed codec (0x131a–0x131c): 32-byte seed (xi)
174            Codec::MlDsa44PrivSeed | Codec::MlDsa65PrivSeed | Codec::MlDsa87PrivSeed => Some(32),
175            // SLH-DSA-SHA2-128s public key: FIPS 205 (32 bytes)
176            Codec::SlhDsaSha2_128sPub => Some(32),
177            _ => None,
178        }
179    }
180}
181
182/// A multicodec-encoded byte slice (borrowed).
183///
184/// `#[repr(transparent)]` guarantees the same memory layout as `[u8]`,
185/// which `MultiEncoded::new` relies on when reinterpreting a borrowed
186/// byte slice as this DST.
187#[derive(Zeroize, ZeroizeOnDrop)]
188#[repr(transparent)]
189pub struct MultiEncoded([u8]);
190
191impl MultiEncoded {
192    /// Create a new multiencoded byte slice, validating the varint
193    /// prefix.
194    pub fn new(bytes: &[u8]) -> Result<&Self, EncodingError> {
195        unsigned_varint::decode::u64(bytes)
196            .map_err(|e| EncodingError::InvalidMulticodec(format!("varint decode: {e}")))?;
197
198        // SAFETY: `MultiEncoded` is a `#[repr(transparent)]` wrapper
199        // around `[u8]`, so `&[u8]` and `&MultiEncoded` have identical
200        // memory layout (including DST metadata). The varint prefix has
201        // been validated above, so every subsequent call into `parts()`
202        // will see well-formed bytes.
203        Ok(unsafe { &*(bytes as *const [u8] as *const MultiEncoded) })
204    }
205
206    /// Size of the byte array (including codec prefix)
207    pub fn len(&self) -> usize {
208        self.0.len()
209    }
210
211    /// Returns true if empty
212    pub fn is_empty(&self) -> bool {
213        self.0.is_empty()
214    }
215
216    /// Separates the codec and the data
217    pub fn parts(&self) -> (u64, &[u8]) {
218        unsigned_varint::decode::u64(&self.0).unwrap()
219    }
220
221    /// Raw codec value (u64)
222    pub fn codec(&self) -> u64 {
223        self.parts().0
224    }
225
226    /// Codec as typed enum
227    pub fn codec_type(&self) -> Codec {
228        Codec::from_u64(self.codec())
229    }
230
231    /// Data bytes (without codec prefix)
232    pub fn data(&self) -> &[u8] {
233        self.parts().1
234    }
235
236    /// Returns the raw bytes, including the codec prefix
237    pub fn as_bytes(&self) -> &[u8] {
238        &self.0
239    }
240}
241
242/// A multicodec-encoded byte buffer (owned)
243#[derive(Clone, Zeroize, ZeroizeOnDrop)]
244pub struct MultiEncodedBuf(Vec<u8>);
245
246impl MultiEncodedBuf {
247    /// Parse an existing multicodec-encoded buffer
248    pub fn new(bytes: Vec<u8>) -> Result<Self, EncodingError> {
249        unsigned_varint::decode::u64(&bytes)
250            .map_err(|e| EncodingError::InvalidMulticodec(format!("varint decode: {e}")))?;
251        Ok(Self(bytes))
252    }
253
254    /// Encode bytes with the given codec
255    pub fn encode(codec: Codec, bytes: &[u8]) -> Self {
256        Self::encode_raw(codec.to_u64(), bytes)
257    }
258
259    /// Encode bytes with a raw codec value (backwards-compatible alias)
260    pub fn encode_bytes(codec: u64, bytes: &[u8]) -> Self {
261        Self::encode_raw(codec, bytes)
262    }
263
264    /// Encode bytes with a raw codec value
265    pub fn encode_raw(codec: u64, bytes: &[u8]) -> Self {
266        let mut codec_buffer = [0u8; 10];
267        let encoded_codec = unsigned_varint::encode::u64(codec, &mut codec_buffer);
268        let mut result = Vec::with_capacity(encoded_codec.len() + bytes.len());
269        result.extend(encoded_codec);
270        result.extend(bytes);
271        Self(result)
272    }
273
274    /// Returns the raw bytes, including the codec prefix
275    /// Note: clones due to ZeroizeOnDrop
276    pub fn into_bytes(self) -> Vec<u8> {
277        self.0.clone()
278    }
279
280    /// Returns a reference to the raw bytes
281    pub fn as_bytes(&self) -> &[u8] {
282        &self.0
283    }
284
285    /// Borrow as MultiEncoded slice.
286    pub fn as_multi_encoded(&self) -> &MultiEncoded {
287        // SAFETY: `MultiEncoded` is `#[repr(transparent)]` over `[u8]`.
288        // The varint prefix was validated when this `MultiEncodedBuf`
289        // was constructed (see `MultiEncodedBuf::new` / `encode_raw`).
290        unsafe { &*(self.0.as_slice() as *const [u8] as *const MultiEncoded) }
291    }
292}
293
294impl AsRef<MultiEncoded> for MultiEncodedBuf {
295    fn as_ref(&self) -> &MultiEncoded {
296        self.as_multi_encoded()
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn test_encode_decode_ed25519() {
306        let key_bytes = [0u8; 32];
307        let encoded = MultiEncodedBuf::encode(Codec::Ed25519Pub, &key_bytes);
308
309        let decoded = MultiEncoded::new(encoded.as_bytes()).unwrap();
310        assert_eq!(decoded.codec(), ED25519_PUB);
311        assert_eq!(decoded.codec_type(), Codec::Ed25519Pub);
312        assert_eq!(decoded.data(), &key_bytes);
313    }
314
315    #[test]
316    fn test_codec_roundtrip() {
317        for codec in [
318            Codec::Ed25519Pub,
319            Codec::X25519Pub,
320            Codec::P256Pub,
321            Codec::P384Pub,
322        ] {
323            let raw = codec.to_u64();
324            assert_eq!(Codec::from_u64(raw), codec);
325        }
326    }
327
328    /// The post-quantum code points, pinned against the upstream registry.
329    ///
330    /// Verified 2026-09-15 against `multiformats/multicodec` `table.csv` at
331    /// commit `38e3bf3e38f613679d76ef2041d73a8060f8622a`. Every one of these
332    /// rows is `draft` status, which means upstream may legitimately move them
333    /// — and a code point that has moved does not fail loudly. It produces
334    /// `did:key`s and multikeys that this workspace reads back perfectly and no
335    /// other implementation can resolve, which is the kind of defect that is
336    /// found by an interop partner months later rather than by a test.
337    ///
338    /// So this test is a provenance record as much as an assertion: changing a
339    /// constant means changing the value *here* and re-stating which revision
340    /// of the table it was checked against.
341    ///
342    /// Registry names, for grepping upstream: `mldsa-44-pub`, `mldsa-65-pub`,
343    /// `mldsa-87-pub`, `mldsa-44-priv-seed`, `mldsa-65-priv-seed`,
344    /// `mldsa-87-priv-seed`, `slhdsa-sha2-128s-pub`.
345    #[test]
346    fn pqc_code_points_match_the_multicodec_registry() {
347        // ML-DSA public keys (FIPS 204).
348        assert_eq!(ML_DSA_44_PUB, 0x1210, "mldsa-44-pub");
349        assert_eq!(ML_DSA_65_PUB, 0x1211, "mldsa-65-pub");
350        assert_eq!(ML_DSA_87_PUB, 0x1212, "mldsa-87-pub");
351
352        // ML-DSA private keys. The registry has *two* families and we use the
353        // seed one deliberately: `-priv-seed` (0x131a-0x131c) is the 32-byte
354        // seed xi, not the expanded 2560/4032/4896-byte private key
355        // (0x1317-0x1319). Storing the seed is what lets a key be re-derived
356        // from a BIP-32/SLIP-0010 chain, so this choice is load-bearing for
357        // context-scoped key derivation and not merely a size optimisation.
358        assert_eq!(ML_DSA_44_PRIV_SEED, 0x131a, "mldsa-44-priv-seed");
359        assert_eq!(ML_DSA_65_PRIV_SEED, 0x131b, "mldsa-65-priv-seed");
360        assert_eq!(ML_DSA_87_PRIV_SEED, 0x131c, "mldsa-87-priv-seed");
361
362        // SLH-DSA (FIPS 205). Only the SHA2-128s parameter set is implemented.
363        assert_eq!(SLH_DSA_SHA2_128S_PUB, 0x1220, "slhdsa-sha2-128s-pub");
364
365        // Key lengths are the algorithms' own, and are the cheap check that
366        // catches a truncated key without any base64 reasoning.
367        assert_eq!(Codec::MlDsa44Pub.expected_key_length(), Some(1312));
368        assert_eq!(Codec::MlDsa65Pub.expected_key_length(), Some(1952));
369        assert_eq!(Codec::MlDsa87Pub.expected_key_length(), Some(2592));
370        assert_eq!(Codec::MlDsa44PrivSeed.expected_key_length(), Some(32));
371        assert_eq!(Codec::MlDsa65PrivSeed.expected_key_length(), Some(32));
372        assert_eq!(Codec::MlDsa87PrivSeed.expected_key_length(), Some(32));
373        assert_eq!(Codec::SlhDsaSha2_128sPub.expected_key_length(), Some(32));
374
375        // Every PQC codec we hold round-trips through the u64 conversion.
376        for codec in [
377            Codec::MlDsa44Pub,
378            Codec::MlDsa65Pub,
379            Codec::MlDsa87Pub,
380            Codec::MlDsa44PrivSeed,
381            Codec::MlDsa65PrivSeed,
382            Codec::MlDsa87PrivSeed,
383            Codec::SlhDsaSha2_128sPub,
384        ] {
385            assert_eq!(Codec::from_u64(codec.to_u64()), codec, "{codec:?}");
386        }
387    }
388
389    /// SLH-DSA has no registered private-key code point, and that is a fact
390    /// about the registry rather than a gap in this crate.
391    ///
392    /// Re-checked 2026-09-15 at the revision named above: the registry carries
393    /// twelve `slhdsa-*-pub` rows (0x1220-0x122b) and **no** `slhdsa-*-priv` of
394    /// any parameter set. `get_private_keymultibase` therefore refuses an
395    /// SLH-DSA secret rather than encoding one, and
396    /// `affinidi-secrets-resolver` keeps those keys memory-only.
397    ///
398    /// The failure mode this guards against is someone closing that gap from
399    /// this side by picking an unused number. A self-assigned code point
400    /// round-trips perfectly in-workspace and is unreadable everywhere else —
401    /// the same trap a neighbouring TSP implementation fell into with
402    /// private-use-area codecs. If a `slhdsa-*-priv` row is ever registered,
403    /// add the constant and delete this test; do not invent one.
404    #[test]
405    fn slh_dsa_has_no_private_code_point_to_hold() {
406        // The public codec exists...
407        assert!(Codec::SlhDsaSha2_128sPub.is_public());
408
409        // ...and every private codec we hold is an ML-DSA seed. If this list
410        // ever gains an SLH-DSA entry, the comment above is out of date.
411        for codec in [
412            Codec::MlDsa44PrivSeed,
413            Codec::MlDsa65PrivSeed,
414            Codec::MlDsa87PrivSeed,
415        ] {
416            assert!(!codec.is_public(), "{codec:?} must classify as private");
417        }
418    }
419}