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/specs/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 mkit_attest::{Algorithm, ExternalSigner, Signer};
28use mkit_core::layout::RepoLayout;
29use mkit_keystore::{KeyRef, KeySelector, open_backend};
30use zeroize::Zeroizing;
31
32use crate::config::Config;
33
34/// Errors the factory surfaces. Mapped to CLI exit codes by the caller.
35#[derive(Debug)]
36pub enum FactoryError {
37    /// Algorithm name (e.g. `"rsa"`) is not one of `ed25519`, `secp256k1`, `p256`.
38    UnknownAlgorithm(String),
39    /// `--signer` value is not one of `repo-key`, `external`, `keystore`.
40    UnknownSignerKind(String),
41    /// The per-algorithm keyfile is missing. Error message points the
42    /// user at `mkit keygen --algorithm <algo>`.
43    MissingKeyFile { algorithm: Algorithm, path: String },
44    /// The selected keystore key is missing. Error message points the user at
45    /// `mkit key generate --algorithm <algo> --label <label>`.
46    MissingKeystoreKey {
47        algorithm: Algorithm,
48        backend: String,
49        reason: String,
50    },
51    /// Keyfile exists but is not a 32-byte raw secret.
52    InvalidKeyFile { path: String, reason: String },
53    /// `attest.external_signer_path` is empty / relative / unusable.
54    ExternalSignerPath(String),
55    /// Failure surfaced from the mkit-attest signer itself (wraps its
56    /// `Error` as a string; the CLI doesn't need to pattern-match these).
57    Signer(String),
58    /// Failure surfaced from mkit-keystore.
59    Keystore(String),
60}
61
62impl std::fmt::Display for FactoryError {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            Self::UnknownAlgorithm(s) => write!(
66                f,
67                "unknown algorithm '{s}' — expected one of: ed25519, secp256k1, p256"
68            ),
69            Self::UnknownSignerKind(s) => write!(
70                f,
71                "unknown signer '{s}' — expected one of: repo-key, external, keystore"
72            ),
73            Self::MissingKeyFile { algorithm, path } => write!(
74                f,
75                "{algorithm} key file not found at '{path}' — run `mkit keygen --algorithm {algorithm}` first"
76            ),
77            Self::MissingKeystoreKey {
78                algorithm,
79                backend,
80                reason,
81            } => write!(
82                f,
83                "missing keystore signing key for algorithm {algorithm} — run `mkit key generate --backend {backend} --algorithm {algorithm} --label <label>` first: {reason}"
84            ),
85            Self::InvalidKeyFile { path, reason } => {
86                write!(f, "invalid key file '{path}': {reason}")
87            }
88            Self::ExternalSignerPath(s) => {
89                write!(f, "attest.external_signer_path: {s}")
90            }
91            Self::Signer(s) => write!(f, "signer: {s}"),
92            Self::Keystore(s) => write!(f, "keystore: {s}"),
93        }
94    }
95}
96
97impl std::error::Error for FactoryError {}
98
99/// Parse `"ed25519" | "secp256k1" | "p256"` into an [`Algorithm`].
100pub fn parse_algorithm(s: &str) -> Result<Algorithm, FactoryError> {
101    s.parse::<Algorithm>()
102        .map_err(|_| FactoryError::UnknownAlgorithm(s.to_owned()))
103}
104
105/// Build a signer.
106///
107/// * `layout` — the resolved repository layout (keys live under its
108///   common dir).
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    layout: &RepoLayout,
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(layout, 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 (issue #160) is not yet wired into the factory"
177                .to_owned(),
178        )),
179    }
180}
181
182struct KeystoreAttestSigner {
183    algorithm: Algorithm,
184    signer: Box<dyn mkit_keystore::KeySigner>,
185}
186
187impl std::fmt::Debug for KeystoreAttestSigner {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        f.debug_struct("KeystoreAttestSigner")
190            .field("algorithm", &self.algorithm)
191            .field("signer", &"<keystore>")
192            .finish()
193    }
194}
195
196impl Signer for KeystoreAttestSigner {
197    fn algorithm(&self) -> Algorithm {
198        self.algorithm
199    }
200
201    fn keyid(&self) -> Result<String, mkit_attest::Error> {
202        self.signer
203            .keyid()
204            .map(mkit_keystore::KeyId::into_string)
205            .map_err(|error| mkit_attest::Error::ExternalSignerBadResponse(error.to_string()))
206    }
207
208    fn sign(&mut self, pae: &[u8]) -> Result<Vec<u8>, mkit_attest::Error> {
209        self.signer
210            .sign(pae)
211            .map_err(|error| mkit_attest::Error::ExternalSignerBadResponse(error.to_string()))
212    }
213}
214
215fn build_repo_key_signer(
216    layout: &RepoLayout,
217    algorithm: Algorithm,
218    cfg: &Config,
219) -> Result<Box<dyn Signer>, FactoryError> {
220    match algorithm {
221        Algorithm::Ed25519 => {
222            // Reuse the commit signer's key path. As of we no
223            // longer auto-generate the key here — `mkit commit`
224            // doesn't either, and silently creating identities was
225            // half of the C1 confused-deputy class. If the user runs
226            // `mkit attest` against a brand-new repo with no key,
227            // they get the same `MissingKeyFile` error as
228            // `mkit keygen` would point them to.
229            let rel = cfg.signing_key.as_str();
230            let path = crate::config::resolve_key_path(layout, rel).map_err(|e| {
231                FactoryError::InvalidKeyFile {
232                    path: rel.to_owned(),
233                    reason: e.to_string(),
234                }
235            })?;
236            if !path.exists() {
237                return Err(FactoryError::MissingKeyFile {
238                    algorithm,
239                    path: path.display().to_string(),
240                });
241            }
242            let kp =
243                mkit_core::sign::load_key(&path).map_err(|e| FactoryError::InvalidKeyFile {
244                    path: path.display().to_string(),
245                    reason: e.to_string(),
246                })?;
247            Ok(Box::new(mkit_attest::RepoKeySigner::new(kp)))
248        }
249        Algorithm::Secp256k1 => {
250            let rel = cfg.attest.secp256k1_key_path_or_default();
251            let secret = load_raw_secret(layout, rel, algorithm)?;
252            // Borrow through `from_seed_zeroizing` so no plain
253            // `[u8; 32]` is materialised on this frame.
254            let signer = mkit_attest::signer_k256::Secp256k1Signer::from_seed_zeroizing(&secret)
255                .map_err(|e| FactoryError::Signer(e.to_string()))?;
256            Ok(Box::new(signer))
257        }
258        Algorithm::P256 => {
259            let rel = cfg.attest.p256_key_path_or_default();
260            let secret = load_raw_secret(layout, rel, algorithm)?;
261            // Borrow through `from_seed_zeroizing`; see secp256k1 arm.
262            let signer = mkit_attest::signer_p256::P256Signer::from_seed_zeroizing(&secret)
263                .map_err(|e| FactoryError::Signer(e.to_string()))?;
264            Ok(Box::new(signer))
265        }
266        #[cfg(feature = "bls-threshold")]
267        Algorithm::Bls12381Threshold => Err(FactoryError::UnknownAlgorithm(
268            "bls12381-thr repo-key signer (issue #160) awaits the release-party CLI".to_owned(),
269        )),
270    }
271}
272
273fn build_external_signer(
274    algorithm: Algorithm,
275    config: &crate::config::AttestConfig,
276) -> Result<Box<dyn Signer>, FactoryError> {
277    if config.external_signer_path.is_empty() {
278        return Err(FactoryError::ExternalSignerPath(
279            "empty — set `attest.external_signer_path` in user-scoped \
280             config ($XDG_CONFIG_HOME/mkit/config). Per-repo .mkit/config \
281             cannot set this key (security)."
282                .into(),
283        ));
284    }
285    let mut ext = ExternalSigner::with_algorithm(&config.external_signer_path, algorithm)
286        .map_err(|e| FactoryError::ExternalSignerPath(e.to_string()))?
287        .with_args(config.external_signer_args.clone());
288    // Apply the user-scoped timeout override if set; otherwise the
289    // crate default (DEFAULT_EXTERNAL_SIGNER_TIMEOUT, 120s) stays in
290    // effect. A 0 value means "fail fast" (deadline already passed).
291    if let Some(secs) = config.external_signer_timeout_secs {
292        ext = ext.with_timeout(std::time::Duration::from_secs(secs));
293    }
294    Ok(Box::new(ext))
295}
296
297fn load_raw_secret(
298    layout: &RepoLayout,
299    rel_path: &str,
300    algorithm: Algorithm,
301) -> Result<Zeroizing<[u8; 32]>, FactoryError> {
302    let path = crate::config::resolve_key_path(layout, rel_path).map_err(|e| {
303        FactoryError::InvalidKeyFile {
304            path: rel_path.to_owned(),
305            reason: e.to_string(),
306        }
307    })?;
308    if !path.exists() {
309        // Report the resolved absolute path, matching the ed25519 arm so
310        // the missing-key error is uniform across algorithms.
311        return Err(FactoryError::MissingKeyFile {
312            algorithm,
313            path: path.display().to_string(),
314        });
315    }
316    mkit_core::sign::load_raw_32(&path).map_err(|e| FactoryError::InvalidKeyFile {
317        path: rel_path.to_owned(),
318        reason: e.to_string(),
319    })
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use std::fs;
326    use std::path::Path;
327
328    /// Harden `path`'s permissions to `mode` (e.g. `0o600` for a key file,
329    /// `0o700` for its parent dir). Unix-only: `mkit_core::sign::load_raw_32`
330    /// only enforces permission bits under `#[cfg(unix)]` (see sign.rs), so
331    /// on other platforms this is a deliberate no-op rather than a call into
332    /// an API (`PermissionsExt::set_mode`) that doesn't exist there.
333    #[cfg(unix)]
334    fn set_secure_mode(path: &Path, mode: u32) {
335        use std::os::unix::fs::PermissionsExt;
336        let mut perm = fs::metadata(path).unwrap().permissions();
337        perm.set_mode(mode);
338        fs::set_permissions(path, perm).unwrap();
339    }
340
341    #[cfg(not(unix))]
342    fn set_secure_mode(_path: &Path, _mode: u32) {}
343
344    /// Helper: write a 32-byte raw key file with mode 0600 and parent
345    /// dir 0700 so `mkit_core::sign::load_key` accepts it.
346    fn write_ed25519_key(path: &Path, bytes: &[u8; 32]) {
347        if let Some(parent) = path.parent() {
348            fs::create_dir_all(parent).unwrap();
349            set_secure_mode(parent, 0o700);
350        }
351        fs::write(path, bytes).unwrap();
352        set_secure_mode(path, 0o600);
353    }
354
355    #[test]
356    fn parse_algorithm_round_trip() {
357        assert_eq!(parse_algorithm("ed25519").unwrap(), Algorithm::Ed25519);
358        assert_eq!(parse_algorithm("secp256k1").unwrap(), Algorithm::Secp256k1);
359        assert_eq!(parse_algorithm("p256").unwrap(), Algorithm::P256);
360    }
361
362    #[test]
363    fn parse_algorithm_rejects_unknown() {
364        match parse_algorithm("rsa") {
365            Err(FactoryError::UnknownAlgorithm(s)) => assert_eq!(s, "rsa"),
366            Err(other) => panic!("unexpected error: {other}"),
367            Ok(_) => panic!("unexpected success"),
368        }
369    }
370
371    #[test]
372    fn unknown_signer_kind_errors() {
373        let td = tempfile::tempdir().unwrap();
374        let cfg = Config::with_defaults();
375        match build_signer(
376            &RepoLayout::single(td.path()),
377            Algorithm::Ed25519,
378            "sigstore",
379            &cfg,
380        ) {
381            Err(FactoryError::UnknownSignerKind(s)) => assert_eq!(s, "sigstore"),
382            Err(other) => panic!("unexpected error: {other}"),
383            Ok(_) => panic!("unexpected success"),
384        }
385    }
386
387    /// `mkit attest --signer repo-key` no longer auto-generates
388    /// the Ed25519 key. Same contract as `mkit commit` — silent
389    /// identity creation was half of the C1 confused-deputy class.
390    #[test]
391    fn repo_key_ed25519_missing_key_errors_with_keygen_hint() {
392        let td = tempfile::tempdir().unwrap();
393        let cfg = Config::with_defaults();
394        match build_signer(
395            &RepoLayout::single(td.path()),
396            Algorithm::Ed25519,
397            "repo-key",
398            &cfg,
399        ) {
400            Err(FactoryError::MissingKeyFile { algorithm, path }) => {
401                assert_eq!(algorithm, Algorithm::Ed25519);
402                assert!(path.contains("default.key"), "{path}");
403            }
404            Err(other) => panic!("unexpected error: {other}"),
405            Ok(_) => panic!("unexpected success"),
406        }
407        assert!(
408            !td.path().join(".mkit/keys/default.key").exists(),
409            "factory must not silently create the key file"
410        );
411    }
412
413    #[test]
414    fn repo_key_ed25519_loads_existing_key() {
415        let td = tempfile::tempdir().unwrap();
416        let key_path = td.path().join(".mkit/keys/default.key");
417        write_ed25519_key(&key_path, &[0xCDu8; 32]);
418        let cfg = Config::with_defaults();
419        let signer = build_signer(
420            &RepoLayout::single(td.path()),
421            Algorithm::Ed25519,
422            "repo-key",
423            &cfg,
424        )
425        .expect("ed25519 repo-key should load existing key");
426        assert_eq!(signer.algorithm(), Algorithm::Ed25519);
427    }
428
429    /// User points `signing_key` at a non-default location via
430    /// user-scoped config; the factory honours it.
431    #[test]
432    fn repo_key_ed25519_honours_signing_key_config() {
433        let td = tempfile::tempdir().unwrap();
434        let key_path = td.path().join(".mkit/keys/custom-global.key");
435        write_ed25519_key(&key_path, &[0xEFu8; 32]);
436        let mut cfg = Config::with_defaults();
437        cfg.signing_key = ".mkit/keys/custom-global.key".into();
438        let signer = build_signer(
439            &RepoLayout::single(td.path()),
440            Algorithm::Ed25519,
441            "repo-key",
442            &cfg,
443        )
444        .expect("custom signing_key path should load");
445        assert_eq!(signer.algorithm(), Algorithm::Ed25519);
446    }
447
448    #[test]
449    fn repo_key_secp256k1_missing_key_errors_with_keygen_hint() {
450        let td = tempfile::tempdir().unwrap();
451        let cfg = Config::with_defaults();
452        match build_signer(
453            &RepoLayout::single(td.path()),
454            Algorithm::Secp256k1,
455            "repo-key",
456            &cfg,
457        ) {
458            Err(FactoryError::MissingKeyFile { algorithm, path }) => {
459                assert_eq!(algorithm, Algorithm::Secp256k1);
460                assert!(path.contains("secp256k1"));
461            }
462            Err(other) => panic!("unexpected error: {other}"),
463            Ok(_) => panic!("unexpected success"),
464        }
465    }
466
467    #[test]
468    fn repo_key_p256_loads_existing_raw_secret() {
469        let td = tempfile::tempdir().unwrap();
470        fs::create_dir_all(td.path().join(".mkit/keys")).unwrap();
471        let mut secret = [0u8; 32];
472        secret[31] = 3;
473        fs::write(td.path().join(".mkit/keys/p256.key"), secret).unwrap();
474        set_secure_mode(&td.path().join(".mkit/keys/p256.key"), 0o600);
475        set_secure_mode(&td.path().join(".mkit/keys"), 0o700);
476
477        let cfg = Config::with_defaults();
478        let signer = build_signer(
479            &RepoLayout::single(td.path()),
480            Algorithm::P256,
481            "repo-key",
482            &cfg,
483        )
484        .expect("p256 repo-key should load raw secret");
485        assert_eq!(signer.algorithm(), Algorithm::P256);
486    }
487
488    #[test]
489    fn repo_key_wrong_length_key_errors() {
490        let td = tempfile::tempdir().unwrap();
491        fs::create_dir_all(td.path().join(".mkit/keys")).unwrap();
492        fs::write(td.path().join(".mkit/keys/secp256k1.key"), b"short").unwrap();
493        set_secure_mode(&td.path().join(".mkit/keys/secp256k1.key"), 0o600);
494        set_secure_mode(&td.path().join(".mkit/keys"), 0o700);
495
496        let cfg = Config::with_defaults();
497        match build_signer(
498            &RepoLayout::single(td.path()),
499            Algorithm::Secp256k1,
500            "repo-key",
501            &cfg,
502        ) {
503            Err(FactoryError::InvalidKeyFile { reason, .. }) => {
504                assert!(reason.contains("32 bytes"), "{reason}");
505            }
506            Err(other) => panic!("unexpected error: {other}"),
507            Ok(_) => panic!("unexpected success"),
508        }
509    }
510
511    #[test]
512    fn external_signer_requires_path() {
513        let td = tempfile::tempdir().unwrap();
514        let cfg = Config::with_defaults();
515        match build_signer(
516            &RepoLayout::single(td.path()),
517            Algorithm::Ed25519,
518            "external",
519            &cfg,
520        ) {
521            Err(FactoryError::ExternalSignerPath(_)) => {}
522            Err(other) => panic!("unexpected error: {other}"),
523            Ok(_) => panic!("unexpected success"),
524        }
525    }
526}