mkit_attest/algorithm.rs
1//! Algorithm identifiers for mkit signing.
2//!
3//! Uses COSE numeric IDs from the IANA registry
4//! (<https://www.iana.org/assignments/cose/cose.xhtml>) so clients on
5//! different platforms can interop at the wire:
6//!
7//! * `Ed25519` — COSE `-19` (fully-specified `EdDSA` w/ Ed25519). The
8//! default mkit signer algorithm.
9//! * `Secp256k1` — COSE `-47` (`ES256K`, secp256k1 + SHA-256). Used by
10//! wallet / browser-crypto clients.
11//! * `P256` — COSE `-7` (`ES256`, P-256 + SHA-256). Used by iOS
12//! Secure Enclave and `WebAuthn` clients.
13//!
14//! The canonical `keyid` shape is `"<prefix>:<hex-pubkey>"` where
15//! `<prefix>` is per-algorithm ([`Algorithm::prefix`]). The legacy
16//! `blake3:` prefix is accepted by [`Algorithm::from_keyid`] and maps
17//! to `Ed25519` for backward compatibility with attestations produced
18//! before the multi-algorithm split.
19
20use core::fmt;
21use core::str::FromStr;
22
23/// Signing algorithm. One variant per supported (curve, hash) pair.
24///
25/// The enum is `Copy` because it is a small fixed tag; callers are
26/// expected to pass it by value through trait and function signatures.
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
28pub enum Algorithm {
29 /// Ed25519 (COSE `-19`). Default mkit signer algorithm.
30 Ed25519,
31 /// secp256k1 + SHA-256 (COSE `-47`, `ES256K`).
32 Secp256k1,
33 /// P-256 + SHA-256 (COSE `-7`, `ES256`).
34 P256,
35 /// BLS12-381 M-of-N threshold (`MinSig` variant — signature in
36 /// G1, public key in G2). Used by the release-party flow; see
37 /// `docs/specs/SPEC-RELEASE-THRESHOLD.md`. No COSE id is registered for
38 /// BLS threshold; [`Algorithm::cose_id`] returns a **provisional**
39 /// project-local negative integer (`-256`) chosen outside the IANA
40 /// reserved ranges. This value is NOT stable — it will be swapped
41 /// for the assigned id (in a patch release) if IANA ever registers
42 /// one, so do not persist it as a long-lived identifier. The stable
43 /// wire-level identifier is `Algorithm::Bls12381Threshold
44 /// = 5` in the mkit-rpc proto.
45 ///
46 /// Feature-gated behind `bls-threshold` because the `Signer`
47 /// implementation pulls a sizeable dep tree (blst); the enum
48 /// variant lives or dies with the implementation it identifies.
49 #[cfg(feature = "bls-threshold")]
50 Bls12381Threshold,
51}
52
53impl Algorithm {
54 /// COSE numeric identifier per IANA registry.
55 ///
56 /// * `Ed25519` — `-19` (fully-specified `EdDSA` w/ Ed25519)
57 /// * `Secp256k1` — `-47` (ES256K)
58 /// * `P256` — `-7` (ES256)
59 #[must_use]
60 pub fn cose_id(self) -> i32 {
61 match self {
62 Self::Ed25519 => -19,
63 Self::Secp256k1 => -47,
64 Self::P256 => -7,
65 // No IANA COSE id is registered for BLS12-381 threshold.
66 // -256 is a PROVISIONAL project-local id, outside the
67 // assigned range (lowest assigned is -65535 in 2026) and
68 // gives us room — if IANA ever registers one, we swap to the
69 // assigned value in a patch release. Treat it as unstable.
70 #[cfg(feature = "bls-threshold")]
71 Self::Bls12381Threshold => -256,
72 }
73 }
74
75 /// Short textual prefix used at the front of the canonical keyid.
76 ///
77 /// `<prefix>:<hex-pubkey>` is the canonical keyid shape. See
78 /// `docs/specs/SPEC-ATTESTATIONS.md` §6.3.
79 #[must_use]
80 pub fn prefix(self) -> &'static str {
81 match self {
82 Self::Ed25519 => "ed25519",
83 Self::Secp256k1 => "secp256k1",
84 Self::P256 => "p256",
85 // Matches the `KEYID_PREFIX` constant in
86 // `signer_bls_threshold` (minus the trailing colon).
87 #[cfg(feature = "bls-threshold")]
88 Self::Bls12381Threshold => "bls12381-thr",
89 }
90 }
91
92 /// Parse the algorithm out of a `"<prefix>:..."` keyid string.
93 ///
94 /// Accepts the canonical per-algorithm prefixes returned by
95 /// [`Self::prefix`] and, for backward compatibility with
96 /// attestations produced before the multi-algorithm split, the
97 /// legacy `blake3:` prefix (which maps to `Ed25519`).
98 ///
99 /// Returns `None` if the keyid has no `':'` separator or if the
100 /// prefix before the first `':'` is unknown.
101 #[must_use]
102 pub fn from_keyid(keyid: &str) -> Option<Self> {
103 let (prefix, _) = keyid.split_once(':')?;
104 match prefix {
105 "ed25519" | "blake3" => Some(Self::Ed25519),
106 "secp256k1" => Some(Self::Secp256k1),
107 "p256" => Some(Self::P256),
108 #[cfg(feature = "bls-threshold")]
109 "bls12381-thr" => Some(Self::Bls12381Threshold),
110 _ => None,
111 }
112 }
113}
114
115impl fmt::Display for Algorithm {
116 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117 f.write_str(self.prefix())
118 }
119}
120
121/// `FromStr` parses the canonical prefix form (`"ed25519"`,
122/// `"secp256k1"`, `"p256"`). Does NOT accept keyid strings with a
123/// trailing `":hex"` — use [`Algorithm::from_keyid`] for that.
124impl FromStr for Algorithm {
125 type Err = UnknownAlgorithm;
126
127 fn from_str(s: &str) -> Result<Self, Self::Err> {
128 match s {
129 "ed25519" => Ok(Self::Ed25519),
130 "secp256k1" => Ok(Self::Secp256k1),
131 "p256" => Ok(Self::P256),
132 #[cfg(feature = "bls-threshold")]
133 "bls12381-thr" => Ok(Self::Bls12381Threshold),
134 other => Err(UnknownAlgorithm(other.to_owned())),
135 }
136 }
137}
138
139/// Error returned by `<Algorithm as FromStr>::from_str` for an unknown
140/// prefix. Carries the offending string so callers can surface it.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct UnknownAlgorithm(pub String);
143
144impl fmt::Display for UnknownAlgorithm {
145 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146 write!(f, "unknown algorithm prefix: {}", self.0)
147 }
148}
149
150impl std::error::Error for UnknownAlgorithm {}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 #[test]
157 fn cose_ids_match_iana_registry() {
158 assert_eq!(Algorithm::Ed25519.cose_id(), -19);
159 assert_eq!(Algorithm::Secp256k1.cose_id(), -47);
160 assert_eq!(Algorithm::P256.cose_id(), -7);
161 }
162
163 #[test]
164 fn prefix_strings() {
165 assert_eq!(Algorithm::Ed25519.prefix(), "ed25519");
166 assert_eq!(Algorithm::Secp256k1.prefix(), "secp256k1");
167 assert_eq!(Algorithm::P256.prefix(), "p256");
168 }
169
170 #[test]
171 fn display_uses_prefix() {
172 assert_eq!(Algorithm::Ed25519.to_string(), "ed25519");
173 assert_eq!(Algorithm::Secp256k1.to_string(), "secp256k1");
174 assert_eq!(Algorithm::P256.to_string(), "p256");
175 }
176
177 #[test]
178 fn from_str_roundtrip_through_prefix() {
179 for alg in [Algorithm::Ed25519, Algorithm::Secp256k1, Algorithm::P256] {
180 let parsed: Algorithm = alg.prefix().parse().expect("round-trip parse");
181 assert_eq!(parsed, alg);
182 }
183 }
184
185 #[test]
186 fn from_str_rejects_unknown() {
187 let err: Result<Algorithm, _> = "rsa".parse();
188 assert!(err.is_err());
189 let err = "".parse::<Algorithm>().unwrap_err();
190 assert_eq!(err.0, "");
191 }
192
193 #[test]
194 fn from_keyid_parses_canonical_prefixes() {
195 assert_eq!(
196 Algorithm::from_keyid("ed25519:abc"),
197 Some(Algorithm::Ed25519)
198 );
199 assert_eq!(
200 Algorithm::from_keyid("secp256k1:dead"),
201 Some(Algorithm::Secp256k1)
202 );
203 assert_eq!(Algorithm::from_keyid("p256:feed"), Some(Algorithm::P256));
204 }
205
206 #[test]
207 fn from_keyid_legacy_blake3_maps_to_ed25519() {
208 // Backward compat with attestations produced before the
209 // multi-algorithm split. repo-key signer still emits
210 // `blake3:<hex>` as its keyid.
211 assert_eq!(
212 Algorithm::from_keyid("blake3:deadbeef"),
213 Some(Algorithm::Ed25519)
214 );
215 }
216
217 #[test]
218 fn from_keyid_unknown_prefix_returns_none() {
219 assert_eq!(Algorithm::from_keyid("rsa:abc"), None);
220 assert_eq!(Algorithm::from_keyid("sigstore:https://x"), None);
221 }
222
223 #[test]
224 fn from_keyid_missing_colon_returns_none() {
225 assert_eq!(Algorithm::from_keyid("ed25519"), None);
226 assert_eq!(Algorithm::from_keyid(""), None);
227 }
228
229 #[test]
230 fn from_keyid_handles_multiple_colons() {
231 // Only the first `:` splits prefix from body.
232 assert_eq!(
233 Algorithm::from_keyid("sigstore:https://example.com/workflow"),
234 None
235 );
236 // A trailing `:...:...` body is still valid prefix-lookup.
237 assert_eq!(
238 Algorithm::from_keyid("ed25519:aa:bb"),
239 Some(Algorithm::Ed25519)
240 );
241 }
242}