Skip to main content

mkit_cli/commands/
attest_factory.rs

1//! Factory helpers for building a [`Signer`] from CLI / config inputs.
2//!
3//! The attest / verify-attest commands each need to turn a
4//! `(algorithm, signer_kind, Config)` quartet into a concrete
5//! `Box<dyn Signer>`. Centralising the dispatch keeps the commands
6//! thin and makes the algorithm -> signer-impl mapping a single place
7//! to audit.
8//!
9//! Key material layout on disk (per `docs/SPEC-ATTESTATIONS.md` §6.1):
10//!
11//! * Ed25519 — path resolved from `cfg.signing_key`
12//! (default `.mkit/keys/default.key`). Shared with the commit signer.
13//! **Not** auto-generated — the caller must run
14//! `mkit keygen` first, matching `mkit commit`'s contract. This is
15//! the same property that closes the C1 attack surface (see
16//! `docs/THREAT-MODEL.md`): no command silently creates a key file
17//! from a path the config could control.
18//! * secp256k1 / p256 — path resolved from
19//! `attest.{secp256k1,p256}_key_path` (user-scoped only; default
20//! `.mkit/keys/<algo>.key`). Raw 32-byte secret, mode 0600. Same
21//! no-auto-generate contract; absent file → clear error.
22//!
23//! The `external` signer kind handles all three algorithms via a single
24//! subprocess binary; the algorithm is recorded so verification can
25//! dispatch the right crypto path without reparsing the keyid.
26
27use std::path::Path;
28
29use mkit_attest::{Algorithm, ExternalSigner, Signer};
30use mkit_keystore::{KeyRef, KeySelector, open_backend};
31use zeroize::Zeroizing;
32
33use crate::config::Config;
34
35/// Errors the factory surfaces. Mapped to CLI exit codes by the caller.
36#[derive(Debug)]
37pub enum FactoryError {
38    /// Algorithm name (e.g. `"rsa"`) is not one of `ed25519`, `secp256k1`, `p256`.
39    UnknownAlgorithm(String),
40    /// `--signer` value is not one of `repo-key`, `external`, `keystore`.
41    UnknownSignerKind(String),
42    /// The per-algorithm keyfile is missing. Error message points the
43    /// user at `mkit keygen --algorithm <algo>`.
44    MissingKeyFile { algorithm: Algorithm, path: String },
45    /// The selected keystore key is missing. Error message points the user at
46    /// `mkit key generate --algorithm <algo> --label <label>`.
47    MissingKeystoreKey {
48        algorithm: Algorithm,
49        backend: String,
50        reason: String,
51    },
52    /// Keyfile exists but is not a 32-byte raw secret.
53    InvalidKeyFile { path: String, reason: String },
54    /// `attest.external_signer_path` is empty / relative / unusable.
55    ExternalSignerPath(String),
56    /// Failure surfaced from the mkit-attest signer itself (wraps its
57    /// `Error` as a string; the CLI doesn't need to pattern-match these).
58    Signer(String),
59    /// Failure surfaced from mkit-keystore.
60    Keystore(String),
61}
62
63impl std::fmt::Display for FactoryError {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        match self {
66            Self::UnknownAlgorithm(s) => write!(
67                f,
68                "unknown algorithm '{s}' — expected one of: ed25519, secp256k1, p256"
69            ),
70            Self::UnknownSignerKind(s) => write!(
71                f,
72                "unknown signer '{s}' — expected one of: repo-key, external, keystore"
73            ),
74            Self::MissingKeyFile { algorithm, path } => write!(
75                f,
76                "{algorithm} key file not found at '{path}' — run `mkit keygen --algorithm {algorithm}` first"
77            ),
78            Self::MissingKeystoreKey {
79                algorithm,
80                backend,
81                reason,
82            } => write!(
83                f,
84                "missing keystore signing key for algorithm {algorithm} — run `mkit key generate --backend {backend} --algorithm {algorithm} --label <label>` first: {reason}"
85            ),
86            Self::InvalidKeyFile { path, reason } => {
87                write!(f, "invalid key file '{path}': {reason}")
88            }
89            Self::ExternalSignerPath(s) => {
90                write!(f, "attest.external_signer_path: {s}")
91            }
92            Self::Signer(s) => write!(f, "signer: {s}"),
93            Self::Keystore(s) => write!(f, "keystore: {s}"),
94        }
95    }
96}
97
98impl std::error::Error for FactoryError {}
99
100/// Parse `"ed25519" | "secp256k1" | "p256"` into an [`Algorithm`].
101pub fn parse_algorithm(s: &str) -> Result<Algorithm, FactoryError> {
102    s.parse::<Algorithm>()
103        .map_err(|_| FactoryError::UnknownAlgorithm(s.to_owned()))
104}
105
106/// Build a signer.
107///
108/// * `root` — the repo root (the `.mkit/` directory lives directly under it).
109/// * `algorithm` — resolved [`Algorithm`].
110/// * `signer_kind` — `"repo-key"`, `"external"`, or `"keystore"`.
111/// * `cfg` — the merged config (defaults + user-scoped + repo-scoped).
112///
113/// The returned signer is ready to be called with PAE bytes.
114pub fn build_signer(
115    root: &Path,
116    algorithm: Algorithm,
117    signer_kind: &str,
118    cfg: &Config,
119) -> Result<Box<dyn Signer>, FactoryError> {
120    match signer_kind {
121        "repo-key" => build_repo_key_signer(root, algorithm, cfg),
122        "external" => build_external_signer(algorithm, &cfg.attest),
123        "keystore" => build_keystore_signer(algorithm, cfg),
124        other => Err(FactoryError::UnknownSignerKind(other.to_owned())),
125    }
126}
127
128fn build_keystore_signer(
129    algorithm: Algorithm,
130    cfg: &Config,
131) -> Result<Box<dyn Signer>, FactoryError> {
132    let key_ref = configured_key_ref(cfg, algorithm)
133        .parse::<KeyRef>()
134        .map_err(|error| FactoryError::Keystore(format!("key ref: {error}")))?;
135    let store = open_backend(key_ref.backend())
136        .map_err(|error| FactoryError::Keystore(error.to_string()))?;
137    let keystore_algorithm = to_keystore_algorithm(algorithm)?;
138    let backend = key_ref.backend().to_string();
139    let label = key_ref.label().to_owned();
140    let selector = KeySelector::new(label.clone(), Some(keystore_algorithm))
141        .map_err(|error| FactoryError::Keystore(error.to_string()))?;
142    let opener = store
143        .opener()
144        .ok_or_else(|| FactoryError::Keystore(format!("backend {backend} cannot open keys")))?;
145    let signer = opener.open(&selector).map_err(|error| match error {
146        mkit_keystore::Error::KeyNotFound(_) => FactoryError::MissingKeystoreKey {
147            algorithm,
148            backend,
149            reason: error.to_string(),
150        },
151        other => FactoryError::Keystore(other.to_string()),
152    })?;
153    Ok(Box::new(KeystoreAttestSigner { algorithm, signer }))
154}
155
156fn configured_key_ref(cfg: &Config, algorithm: Algorithm) -> &str {
157    match algorithm {
158        Algorithm::Ed25519 => cfg.key.ed25519_ref_or_fallback(),
159        Algorithm::Secp256k1 => cfg.key.secp256k1_ref_or_fallback(),
160        Algorithm::P256 => cfg.key.p256_ref_or_fallback(),
161        #[cfg(feature = "bls-threshold")]
162        Algorithm::Bls12381Threshold => "",
163    }
164}
165
166// `Result` is necessary on the `bls-threshold` arm; without that
167// feature, clippy sees only infallible arms and flags the wrap.
168#[allow(clippy::unnecessary_wraps)]
169fn to_keystore_algorithm(algorithm: Algorithm) -> Result<mkit_keystore::Algorithm, FactoryError> {
170    match algorithm {
171        Algorithm::Ed25519 => Ok(mkit_keystore::Algorithm::Ed25519),
172        Algorithm::Secp256k1 => Ok(mkit_keystore::Algorithm::Secp256k1),
173        Algorithm::P256 => Ok(mkit_keystore::Algorithm::P256),
174        #[cfg(feature = "bls-threshold")]
175        Algorithm::Bls12381Threshold => Err(FactoryError::UnknownAlgorithm(
176            "bls12381-thr keystore backend is Phase 2 of issue #160".to_owned(),
177        )),
178    }
179}
180
181struct KeystoreAttestSigner {
182    algorithm: Algorithm,
183    signer: Box<dyn mkit_keystore::KeySigner>,
184}
185
186impl std::fmt::Debug for KeystoreAttestSigner {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        f.debug_struct("KeystoreAttestSigner")
189            .field("algorithm", &self.algorithm)
190            .field("signer", &"<keystore>")
191            .finish()
192    }
193}
194
195impl Signer for KeystoreAttestSigner {
196    fn algorithm(&self) -> Algorithm {
197        self.algorithm
198    }
199
200    fn keyid(&self) -> Result<String, mkit_attest::Error> {
201        self.signer
202            .keyid()
203            .map(mkit_keystore::KeyId::into_string)
204            .map_err(|error| mkit_attest::Error::ExternalSignerBadResponse(error.to_string()))
205    }
206
207    fn sign(&mut self, pae: &[u8]) -> Result<Vec<u8>, mkit_attest::Error> {
208        self.signer
209            .sign(pae)
210            .map_err(|error| mkit_attest::Error::ExternalSignerBadResponse(error.to_string()))
211    }
212}
213
214fn build_repo_key_signer(
215    root: &Path,
216    algorithm: Algorithm,
217    cfg: &Config,
218) -> Result<Box<dyn Signer>, FactoryError> {
219    match algorithm {
220        Algorithm::Ed25519 => {
221            // Reuse the commit signer's key path. As of we no
222            // longer auto-generate the key here — `mkit commit`
223            // doesn't either, and silently creating identities was
224            // half of the C1 confused-deputy class. If the user runs
225            // `mkit attest` against a brand-new repo with no key,
226            // they get the same `MissingKeyFile` error as
227            // `mkit keygen` would point them to.
228            let rel = cfg.signing_key.as_str();
229            let path = crate::config::resolve_key_path(root, rel).map_err(|e| {
230                FactoryError::InvalidKeyFile {
231                    path: rel.to_owned(),
232                    reason: e.to_string(),
233                }
234            })?;
235            if !path.exists() {
236                return Err(FactoryError::MissingKeyFile {
237                    algorithm,
238                    path: path.display().to_string(),
239                });
240            }
241            let kp =
242                mkit_core::sign::load_key(&path).map_err(|e| FactoryError::InvalidKeyFile {
243                    path: path.display().to_string(),
244                    reason: e.to_string(),
245                })?;
246            Ok(Box::new(mkit_attest::RepoKeySigner::new(kp)))
247        }
248        Algorithm::Secp256k1 => {
249            let rel = cfg.attest.secp256k1_key_path_or_default();
250            let secret = load_raw_secret(root, rel, algorithm)?;
251            // Borrow through `from_seed_zeroizing` so no plain
252            // `[u8; 32]` is materialised on this frame.
253            let signer = mkit_attest::signer_k256::Secp256k1Signer::from_seed_zeroizing(&secret)
254                .map_err(|e| FactoryError::Signer(e.to_string()))?;
255            Ok(Box::new(signer))
256        }
257        Algorithm::P256 => {
258            let rel = cfg.attest.p256_key_path_or_default();
259            let secret = load_raw_secret(root, rel, algorithm)?;
260            // Borrow through `from_seed_zeroizing`; see secp256k1 arm.
261            let signer = mkit_attest::signer_p256::P256Signer::from_seed_zeroizing(&secret)
262                .map_err(|e| FactoryError::Signer(e.to_string()))?;
263            Ok(Box::new(signer))
264        }
265        #[cfg(feature = "bls-threshold")]
266        Algorithm::Bls12381Threshold => Err(FactoryError::UnknownAlgorithm(
267            "bls12381-thr repo-key signer is Phase 3 of issue #160 (release-party CLI)".to_owned(),
268        )),
269    }
270}
271
272fn build_external_signer(
273    algorithm: Algorithm,
274    config: &crate::config::AttestConfig,
275) -> Result<Box<dyn Signer>, FactoryError> {
276    if config.external_signer_path.is_empty() {
277        return Err(FactoryError::ExternalSignerPath(
278            "empty — set `attest.external_signer_path` in user-scoped \
279             config ($XDG_CONFIG_HOME/mkit/config). Per-repo .mkit/config \
280             cannot set this key (security)."
281                .into(),
282        ));
283    }
284    let mut ext = ExternalSigner::with_algorithm(&config.external_signer_path, algorithm)
285        .map_err(|e| FactoryError::ExternalSignerPath(e.to_string()))?
286        .with_args(config.external_signer_args.clone());
287    // Apply the user-scoped timeout override if set; otherwise the
288    // crate default (DEFAULT_EXTERNAL_SIGNER_TIMEOUT, 120s) stays in
289    // effect. A 0 value means "fail fast" (deadline already passed).
290    if let Some(secs) = config.external_signer_timeout_secs {
291        ext = ext.with_timeout(std::time::Duration::from_secs(secs));
292    }
293    Ok(Box::new(ext))
294}
295
296fn load_raw_secret(
297    root: &Path,
298    rel_path: &str,
299    algorithm: Algorithm,
300) -> Result<Zeroizing<[u8; 32]>, FactoryError> {
301    let path = crate::config::resolve_key_path(root, rel_path).map_err(|e| {
302        FactoryError::InvalidKeyFile {
303            path: rel_path.to_owned(),
304            reason: e.to_string(),
305        }
306    })?;
307    if !path.exists() {
308        return Err(FactoryError::MissingKeyFile {
309            algorithm,
310            path: rel_path.to_owned(),
311        });
312    }
313    mkit_core::sign::load_raw_32(&path).map_err(|e| FactoryError::InvalidKeyFile {
314        path: rel_path.to_owned(),
315        reason: e.to_string(),
316    })
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use std::fs;
323    use std::os::unix::fs::PermissionsExt;
324
325    /// Helper: write a 32-byte raw key file with mode 0600 and parent
326    /// dir 0700 so `mkit_core::sign::load_key` accepts it.
327    fn write_ed25519_key(path: &Path, bytes: &[u8; 32]) {
328        if let Some(parent) = path.parent() {
329            fs::create_dir_all(parent).unwrap();
330            let mut p = fs::metadata(parent).unwrap().permissions();
331            p.set_mode(0o700);
332            fs::set_permissions(parent, p).unwrap();
333        }
334        fs::write(path, bytes).unwrap();
335        let mut perm = fs::metadata(path).unwrap().permissions();
336        perm.set_mode(0o600);
337        fs::set_permissions(path, perm).unwrap();
338    }
339
340    #[test]
341    fn parse_algorithm_round_trip() {
342        assert_eq!(parse_algorithm("ed25519").unwrap(), Algorithm::Ed25519);
343        assert_eq!(parse_algorithm("secp256k1").unwrap(), Algorithm::Secp256k1);
344        assert_eq!(parse_algorithm("p256").unwrap(), Algorithm::P256);
345    }
346
347    #[test]
348    fn parse_algorithm_rejects_unknown() {
349        match parse_algorithm("rsa") {
350            Err(FactoryError::UnknownAlgorithm(s)) => assert_eq!(s, "rsa"),
351            Err(other) => panic!("unexpected error: {other}"),
352            Ok(_) => panic!("unexpected success"),
353        }
354    }
355
356    #[test]
357    fn unknown_signer_kind_errors() {
358        let td = tempfile::tempdir().unwrap();
359        let cfg = Config::with_defaults();
360        match build_signer(td.path(), Algorithm::Ed25519, "sigstore", &cfg) {
361            Err(FactoryError::UnknownSignerKind(s)) => assert_eq!(s, "sigstore"),
362            Err(other) => panic!("unexpected error: {other}"),
363            Ok(_) => panic!("unexpected success"),
364        }
365    }
366
367    /// `mkit attest --signer repo-key` no longer auto-generates
368    /// the Ed25519 key. Same contract as `mkit commit` — silent
369    /// identity creation was half of the C1 confused-deputy class.
370    #[test]
371    fn repo_key_ed25519_missing_key_errors_with_keygen_hint() {
372        let td = tempfile::tempdir().unwrap();
373        let cfg = Config::with_defaults();
374        match build_signer(td.path(), Algorithm::Ed25519, "repo-key", &cfg) {
375            Err(FactoryError::MissingKeyFile { algorithm, path }) => {
376                assert_eq!(algorithm, Algorithm::Ed25519);
377                assert!(path.contains("default.key"), "{path}");
378            }
379            Err(other) => panic!("unexpected error: {other}"),
380            Ok(_) => panic!("unexpected success"),
381        }
382        assert!(
383            !td.path().join(".mkit/keys/default.key").exists(),
384            "factory must not silently create the key file"
385        );
386    }
387
388    #[test]
389    fn repo_key_ed25519_loads_existing_key() {
390        let td = tempfile::tempdir().unwrap();
391        let key_path = td.path().join(".mkit/keys/default.key");
392        write_ed25519_key(&key_path, &[0xCDu8; 32]);
393        let cfg = Config::with_defaults();
394        let signer = build_signer(td.path(), Algorithm::Ed25519, "repo-key", &cfg)
395            .expect("ed25519 repo-key should load existing key");
396        assert_eq!(signer.algorithm(), Algorithm::Ed25519);
397    }
398
399    /// User points `signing_key` at a non-default location via
400    /// user-scoped config; the factory honours it.
401    #[test]
402    fn repo_key_ed25519_honours_signing_key_config() {
403        let td = tempfile::tempdir().unwrap();
404        let key_path = td.path().join(".mkit/keys/custom-global.key");
405        write_ed25519_key(&key_path, &[0xEFu8; 32]);
406        let mut cfg = Config::with_defaults();
407        cfg.signing_key = ".mkit/keys/custom-global.key".into();
408        let signer = build_signer(td.path(), Algorithm::Ed25519, "repo-key", &cfg)
409            .expect("custom signing_key path should load");
410        assert_eq!(signer.algorithm(), Algorithm::Ed25519);
411    }
412
413    #[test]
414    fn repo_key_secp256k1_missing_key_errors_with_keygen_hint() {
415        let td = tempfile::tempdir().unwrap();
416        let cfg = Config::with_defaults();
417        match build_signer(td.path(), Algorithm::Secp256k1, "repo-key", &cfg) {
418            Err(FactoryError::MissingKeyFile { algorithm, path }) => {
419                assert_eq!(algorithm, Algorithm::Secp256k1);
420                assert!(path.contains("secp256k1"));
421            }
422            Err(other) => panic!("unexpected error: {other}"),
423            Ok(_) => panic!("unexpected success"),
424        }
425    }
426
427    #[test]
428    fn repo_key_p256_loads_existing_raw_secret() {
429        let td = tempfile::tempdir().unwrap();
430        fs::create_dir_all(td.path().join(".mkit/keys")).unwrap();
431        let mut secret = [0u8; 32];
432        secret[31] = 3;
433        fs::write(td.path().join(".mkit/keys/p256.key"), secret).unwrap();
434        let mut perm = fs::metadata(td.path().join(".mkit/keys/p256.key"))
435            .unwrap()
436            .permissions();
437        perm.set_mode(0o600);
438        fs::set_permissions(td.path().join(".mkit/keys/p256.key"), perm).unwrap();
439        let mut dperm = fs::metadata(td.path().join(".mkit/keys"))
440            .unwrap()
441            .permissions();
442        dperm.set_mode(0o700);
443        fs::set_permissions(td.path().join(".mkit/keys"), dperm).unwrap();
444
445        let cfg = Config::with_defaults();
446        let signer = build_signer(td.path(), Algorithm::P256, "repo-key", &cfg)
447            .expect("p256 repo-key should load raw secret");
448        assert_eq!(signer.algorithm(), Algorithm::P256);
449    }
450
451    #[test]
452    fn repo_key_wrong_length_key_errors() {
453        let td = tempfile::tempdir().unwrap();
454        fs::create_dir_all(td.path().join(".mkit/keys")).unwrap();
455        fs::write(td.path().join(".mkit/keys/secp256k1.key"), b"short").unwrap();
456        let mut perm = fs::metadata(td.path().join(".mkit/keys/secp256k1.key"))
457            .unwrap()
458            .permissions();
459        perm.set_mode(0o600);
460        fs::set_permissions(td.path().join(".mkit/keys/secp256k1.key"), perm).unwrap();
461        let mut dperm = fs::metadata(td.path().join(".mkit/keys"))
462            .unwrap()
463            .permissions();
464        dperm.set_mode(0o700);
465        fs::set_permissions(td.path().join(".mkit/keys"), dperm).unwrap();
466
467        let cfg = Config::with_defaults();
468        match build_signer(td.path(), Algorithm::Secp256k1, "repo-key", &cfg) {
469            Err(FactoryError::InvalidKeyFile { reason, .. }) => {
470                assert!(reason.contains("32 bytes"), "{reason}");
471            }
472            Err(other) => panic!("unexpected error: {other}"),
473            Ok(_) => panic!("unexpected success"),
474        }
475    }
476
477    #[test]
478    fn external_signer_requires_path() {
479        let td = tempfile::tempdir().unwrap();
480        let cfg = Config::with_defaults();
481        match build_signer(td.path(), Algorithm::Ed25519, "external", &cfg) {
482            Err(FactoryError::ExternalSignerPath(_)) => {}
483            Err(other) => panic!("unexpected error: {other}"),
484            Ok(_) => panic!("unexpected success"),
485        }
486    }
487}