Skip to main content

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}