SPEC-KEYSTORE.md
In docs/specs/
In docs/specs/
mkit-keystore crate, its CLI surface (mkit key), and the keystore-backed commit and attestation signersStatus: Normative for mkit-keystore behavior. This file does not
specify wire formats; it specifies the keystore vault abstraction inside
mkit. ("Vault" throughout this document refers to the mkit keystore vault
abstraction, not HashiCorp Vault or any external secret manager.)
Authority: this file is the source of truth for mkit-keystore behavior.
If code, docs, or tests disagree with this file, update the implementation or
amend this specification in the same change.
Scope reminder: this spec covers the crate rust/crates/mkit-keystore, the
mkit key CLI command, the keystore-backed mkit_attest::Signer adapter,
and the keystore-backed commit signer adapter. External signers are specified
by SPEC-EXTERNAL-SIGNER, not here.
mkit treats signatures as trust anchors for commits, remix objects, and
DSSE-wrapped in-toto attestations. Today, long-term signing keys are raw
32-byte files under .mkit/keys/, protected by strict local filesystem
hardening. That is a useful compatibility baseline, but it is not a
cross-platform vault policy.
mkit-keystore provides a generic signing-key vault abstraction over
software, OS-native, and hardware-backed key stores. It is deliberately a
signing keystore, not a protocol lifecycle system. It returns signer handles;
callers decide what those signers are allowed to sign.
mkit-keystore provides the crate, API, CLI, config, a software backend (both
a deterministic raw-compatibility mode and an encrypted-at-rest mode), OS-native
extractable storage backends where implemented, and keystore-backed
commit/attestation signing, with honest capability reporting throughout. It
does not provide Secure Enclave, Windows TPM/CNG provider keys, TPM/PCR-bound
systemd credentials, cloud KMS, PKCS#11/HSM support, or FIDO2/WebAuthn keystore
signing — those remain out of scope unless this spec is amended (§3.2).
This section summarizes what is implemented in the crate today. It is a reading aid; the per-section requirements below remain the normative contract.
Shipped against this spec:
rust/crates/mkit-keystore crate, public API (Keystore, KeySigner,
KeyGenerator, KeyImporter, KeyOpener, KeyLister, KeyExporter,
KeyDeleter), structured Error enum, typed wrappers (KeyLabel,
KeyRefLabel, KeyId, PublicKeyBytes, KeyRef, KeySelector).Capabilities reporting structurally tied to operation-trait availability,
with capability-honesty tests in each backend module.software:<label>,
XChaCha20-Poly1305 + length-prefixed AAD over backend/label/algorithm /
public key/key ID/attrs/protector ID, wrapped DEK in an OS protector).software-raw:<label>), reusing
mkit_core::sign::{load_raw_32, save_raw_32} hardening.macos-keychain,
linux-secret-service, systemd-creds. (Windows is not a supported
target — MKIT-6; see §6.4.)YubiKey backend behind backend-yubikey — OpenPGP signing slot (Ed25519)
and PIV signing slot (P-256). secp256k1 is reported UnsupportedAlgorithm;
FIDO2/CTAP keys are not handled in this backend and remain on the external
signer path.mkit key {generate,list,import,export,delete} CLI with the flags and
defaults specified in §9.signer = keystore) and attestation
signing (attest.signer = keystore).Not yet shipped against this spec:
BackendKind::Memory exists in the enum, but no
MemoryKeystore is implemented in this build).BackendKind::External) — open_backend
returns BackendUnavailable; external signing continues to live in
mkit-rpc per §12.BackendKind::Cloud) — same as above.systemd-creds semantics, bounded PIN/touch prompt providers for YubiKey,
hardware ECDSA verification-equivalence CI for every required platform.mkit-core must remain lean. It must not depend on platform keychain,
credential-manager, D-Bus, TPM, Secure Enclave, YubiKey, or cloud KMS
dependencies.rust/crates/mkit-keystore, behind feature flags where appropriate.mkit keygen command remains as a legacy compatibility surface unless a
future spec explicitly removes it.mkit key ..., not by changing the
meaning of mkit keygen in-place.mkit key generate or legacy mkit keygen.software:<label> is the encrypted-at-rest software backend. Production
mkit-cli builds must enable the target-appropriate OS protector feature,
while mkit-keystore default features remain empty. Raw-file
compatibility is exposed only through the explicit software-raw:<label>
backend token and must not be the secure default.mkit-cli integration through mkit key {generate,list,import,export,delete}.mkit-attest integration through a keystore-backed implementation of its
existing Signer trait.this key signs for chain X.systemd-creds behavior unless implemented and capability-tested.mkit keygen, signing_key) — they
remain supported alongside the keystore.The new crate is:
rust/crates/mkit-keystoreWorkspace integration:
crates/mkit-keystore to rust/Cargo.toml workspace members.mkit-keystore default features remain empty/lean. Production mkit-cli
builds, packages, and CI release gates must enable the platform-appropriate
encrypted software protector feature so the configured software default is
usable on supported targets while the library crate remains feature-gated.Allowed dependencies:
docs/RELEASE.md and
rust/deny.toml.Forbidden dependencies:
mkit-core must not depend on mkit-keystore.mkit-wasm must not depend on native mkit-keystore backends.The API below is normative. Exact module names may change only if this file is updated in the same change.
mkit-keystore must expose an algorithm enum equivalent to
mkit_attest::Algorithm:
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Algorithm {
Ed25519,
Secp256k1,
P256,
/// Feature-gated behind `bls-threshold`. See §6.1.1 below.
#[cfg(feature = "bls-threshold")]
Bls12381Threshold,
}Requirements:
Algorithm must convert to and from mkit_attest::Algorithm when the
attest integration feature is enabled.ed25519, secp256k1, p256, and
bls12381-thr (the BLS variant matches the same prefix as
mkit_attest::Algorithm::Bls12381Threshold).Bls12381Threshold is feature-gated behind bls-threshold. The
generic KeyImporter / KeyExporter traits MUST reject this variant
with UnsupportedAlgorithm — BLS shares are variable-length (≈52
bytes for MinSig) and the 32-byte SecretKey type cannot
represent them. See §6.1.1 for the dedicated BLS share storage API.#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeyAttrs {
pub extractable: bool,
pub require_user_presence: bool,
pub device_bound: bool,
}Semantics:
extractable = true means export may return secret material.extractable = false means export must fail with a typed non-extractable
error.require_user_presence = true requests a per-operation user-presence gate
such as Touch ID, PIN, biometric prompt, or hardware touch.device_bound = true requests that a key cannot be restored onto another
machine through backup or cloud sync.KeyAttrs::default() is { extractable: true, require_user_presence: false,
device_bound: false } because software-style storage decrypts into process
memory. Stronger backends that hold non-extractable keys advertise that
through Capabilities::supports_non_extractable and must reject
extractable = true import paths they cannot honor.
Backends may reject unsupported attribute combinations. They must not silently weaken requested attributes.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Capabilities {
pub backend: BackendKind,
pub algorithms: Vec<Algorithm>,
pub can_generate: bool,
pub can_import: bool,
pub can_export: bool,
pub can_delete: bool,
pub supports_listing: bool,
pub supports_user_presence: bool,
pub supports_device_bound: bool,
pub supports_non_extractable: bool,
}BackendKind must identify the backend family without exposing internal
implementation details:
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BackendKind {
Software,
SoftwareRaw,
MacosKeychain,
LinuxSecretService,
SystemdCreds,
YubiKey,
External,
Cloud,
Memory,
}Requirements:
Keystore registry. They describe structural backend support, not a guarantee
that the current session, daemon, hardware token, or protector is available.#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeyMetadata {
label: KeyLabel,
pub backend: BackendKind,
pub algorithm: Algorithm,
public_key: PublicKeyBytes,
keyid: KeyId,
pub extractable: bool,
pub require_user_presence: bool,
pub device_bound: bool,
}
impl KeyMetadata {
pub fn label(&self) -> &str;
pub fn label_id(&self) -> &KeyLabel;
pub fn backend(&self) -> BackendKind;
pub fn algorithm(&self) -> Algorithm;
pub fn keyid(&self) -> &str;
pub fn key_id(&self) -> &KeyId;
pub fn public_key(&self) -> &[u8];
pub fn public_key_bytes(&self) -> &PublicKeyBytes;
}Public key encodings:
Canonical key IDs:
ed25519:<64 lowercase hex raw pubkey>.blake3:<64 lowercase hex blake3(pubkey)>.secp256k1:<66 lowercase hex compressed SEC1 pubkey>.p256:<66 lowercase hex compressed SEC1 pubkey>.The keystore must expose canonical key IDs. Compatibility adapters may emit
legacy key IDs only where existing verifier contracts require them. Public
KeyId values must be non-empty, must not contain control characters, and must
be at most 256 bytes.
pub struct SecretKey {
algorithm: Algorithm,
bytes: zeroize::Zeroizing<[u8; 32]>,
}
impl SecretKey {
pub fn new(algorithm: Algorithm, bytes: [u8; 32]) -> Self;
pub fn from_zeroizing(
algorithm: Algorithm,
bytes: zeroize::Zeroizing<[u8; 32]>,
) -> Self;
pub fn algorithm(&self) -> Algorithm;
pub fn expose_secret(&self) -> &[u8; 32];
pub fn into_bytes(self) -> zeroize::Zeroizing<[u8; 32]>;
}
impl core::fmt::Debug for SecretKey {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result;
}Requirements:
.mkit/keys/default.key semantics.zeroize or equivalent workspace-approved
zeroization.Debug must be manually implemented and must never include secret bytes.expose_secret or into_bytes.pub trait KeySigner: Send {
fn algorithm(&self) -> Algorithm;
fn label(&self) -> &KeyLabel;
fn metadata(&self) -> Result<KeyMetadata, Error>;
fn public_key(&self) -> Result<PublicKeyBytes, Error>;
fn keyid(&self) -> Result<KeyId, Error>;
fn sign(&mut self, msg: &[u8]) -> Result<Vec<u8>, Error>;
// Defaulted: returns `Error::UnsupportedOperation`. See "Recoverable
// prehash signing" below.
fn sign_prehash_recoverable_secp256k1(
&mut self,
prehash: &[u8; 32],
) -> Result<[u8; 65], Error>;
}Requirements:
sign takes &mut self to support backends with sessions, prompts, retry
counters, cached public-key state, or external connections.Send, not Send + Sync, intentionally. Issue #104 sketched
Send + Sync, but the implementation needs &mut self signing because many
real backends have session state, prompt state, retry counters, cached
handles, or child-process state. A concrete signer may still be Sync, but
callers must not rely on concurrent signing through one handle.sign signs the supplied message according to the selected algorithm's
existing mkit semantics. It must not invent additional protocol-level domain
separation.mkit-core commit signing hash described
in section 10.mkit-attest semantics: ECDSA over
SHA-256 of the supplied bytes, compact r || s, low-S canonical. DSSE passes
the PAE bytes.Recoverable prehash signing. sign cannot produce the SPEC-WRITE-GRANTS
secp256k1-eip191 owner signature: it hashes with SHA-256 and returns no
recovery byte, while that scheme signs a Keccak-256 digest and needs v.
sign_prehash_recoverable_secp256k1 is the one narrow addition for it:
r || s || v with s <= n / 2 and v of 27 or 28 (SPEC-WRITE-GRANTS §4.4).
The digest is signed as given; the signer adds no hashing or domain
separation.software or software-raw backend
implements it. Every other signer — the OS-native backends and
YubiKey, where secp256k1 is anyway UnsupportedAlgorithm — keeps the
default, which fails closed with UnsupportedOperation. A non-secp256k1
software key returns UnsupportedAlgorithm.KeyExporter when it is unsupported: the caller reports that the key
cannot sign natively and offers the import path (an external wallet
signature) instead.Selectors identify keys for read, export, and deletion operations:
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeySelector {
label: KeyLabel,
pub algorithm: Option<Algorithm>,
}
impl KeySelector {
pub fn new(label: impl Into<String>, algorithm: Option<Algorithm>) -> Result<Self, Error>;
pub fn label(&self) -> &str;
pub fn label_id(&self) -> &KeyLabel;
pub fn algorithm(&self) -> Option<Algorithm>;
}algorithm = None means "select by label only" and is valid only when the
backend has exactly one key with that label. If a backend has multiple keys with
the same label across algorithms, label-only selection must fail with an
ambiguous-key error.
pub trait Keystore: Send + Sync {
fn capabilities(&self) -> Capabilities;
fn generator(&self) -> Option<&dyn KeyGenerator>;
fn importer(&self) -> Option<&dyn KeyImporter>;
fn opener(&self) -> Option<&dyn KeyOpener>;
fn lister(&self) -> Option<&dyn KeyLister>;
fn exporter(&self) -> Option<&dyn KeyExporter>;
fn deleter(&self) -> Option<&dyn KeyDeleter>;
}
pub trait KeyGenerator: Send + Sync {
fn generate(
&self,
label: &KeyLabel,
algorithm: Algorithm,
attrs: KeyAttrs,
options: GenerateOptions,
) -> Result<Box<dyn KeySigner>, Error>;
}
pub trait KeyImporter: Send + Sync {
fn import(
&self,
label: &KeyLabel,
secret: SecretKey,
attrs: KeyAttrs,
options: ImportOptions,
) -> Result<Box<dyn KeySigner>, Error>;
}
pub trait KeyOpener: Send + Sync {
fn open(&self, selector: &KeySelector) -> Result<Box<dyn KeySigner>, Error>;
}
pub trait KeyLister: Send + Sync {
fn list(&self) -> Result<Vec<KeyMetadata>, Error>;
}
pub trait KeyExporter: Send + Sync {
fn export(&self, selector: &KeySelector) -> Result<SecretKey, Error>;
}
pub trait KeyDeleter: Send + Sync {
fn delete(&self, selector: &KeySelector) -> Result<(), Error>;
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct GenerateOptions {
pub overwrite: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ImportOptions {
pub overwrite: bool,
}Requirements:
:, path separators, NUL, control
characters, and leading/trailing whitespace.KeySelector.algorithm = Some(...) for open/export/delete.(label, algorithm).generate and import must refuse to overwrite an existing
(label, algorithm) unless options.overwrite = true.options.overwrite = true, replacement semantics are backend-defined but
must never delete or replace any key other than the exact (label, algorithm)
being created/imported. Backends should make replacement atomic where the
underlying vault supports it; otherwise they must document the non-atomic
behavior in their backend notes and tests.open must fail if the selector is missing or ambiguous.list must return deterministic ordering by (backend, label, algorithm)
where the backend can list keys.delete must delete only the selected key and must not delete by prefix.export must fail for non-extractable keys.Keystore; non-exportable backends must not implement KeyExporter.String values.mkit-keystore::Error must distinguish at least:
Errors must be structured enums, not stringly typed. CLI commands may render human-friendly messages from them.
User-facing Display output must redact backend-local labels, selectors,
filesystem paths, and arbitrary backend payloads. Full diagnostics remain
available through structured fields and developer/debug output.
mkit-keystore includes a software backend with two storage-security modes,
described below.
Purpose:
Storage:
$XDG_DATA_HOME/mkit/keys/ on Unix-like platforms,
falling back to ~/.local/share/mkit/keys/ when XDG_DATA_HOME is unset.
Non-Unix platforms must use the platform's per-user application data
directory or a documented equivalent.software:<label> maps to exactly one encrypted key record under that
user-scoped storage root. It must not resolve relative to the current repo.software-raw:<label> maps to exactly one raw compatibility key record under
a raw-specific storage subtree. It exists for deterministic tests,
compatibility, and explicit migration work only. It must never be selected by
default..mkit/keys/* files remain supported only through legacy raw-file
flows such as mkit keygen, signing_key, and repo-key compatibility.mkit-keystore abstraction.0600 key files.0700 parent directories.mkit_core::sign::{load_raw_32, save_raw_32}
security properties.Storage-security modes:
software-raw) preserves the hardened
0600 raw-key behavior of legacy mkit keygen. It does not satisfy the
encrypted-at-rest software-backend acceptance criterion, and must never be
the default.software backend token. It must
encrypt key material at rest
using OS-protected envelope encryption:systemd-creds for headless/server Linux.software backend may auto-select Secret Service only when a
desktop session is detected and the protector opens cleanly. Secret Service
errors must fail closed rather than silently falling back to a weaker
protector. The backend may select systemd-creds for headless/server use
when no desktop Secret Service session is detected, and must fail closed if
no usable protector is available.BackendKind::Software and
documented as software-only, not hardware-bound.supports_device_bound,
supports_non_extractable, or encrypted-at-rest properties unless implemented
truthfully.BackendKind::SoftwareRaw and must not claim encrypted-at-rest protection.EncryptedKeyRecord wire formatThis is the byte-exact on-disk layout of the encrypted software-key
record referenced by the bullet list above (fields bound as AAD;
XChaCha20-Poly1305; OS-protector-wrapped DEK). Every multi-byte integer
is little-endian per SPEC-CONVENTIONS §3. A "length-prefixed field"
below means [u32 LE length][length bytes] — this applies even to the
fixed-24-byte nonce, which still carries its own length prefix rather
than a bare 24-byte slice, for uniformity with every other field.
offset size field
0 8 magic "MKITKSV1"
8 1 version 0x01
9 1 algorithm_id registry below
10 1 attrs_bits key-attribute bitflags (extractable, etc.)
11 … protector length-prefixed UTF-8 (for example, "macos-keychain")
… … public_key length-prefixed raw bytes
… … keyid length-prefixed UTF-8 (canonical keyid string)
… 4+24 nonce length-prefixed; length is always 24
… … wrapped_dek length-prefixed; OS-protector output over the 32-byte DEK
… … ciphertext length-prefixed; XChaCha20-Poly1305 ciphertext + 16-byte tagalgorithm_id registry:
| Value | Algorithm |
|---|---|
0x01 | Ed25519 |
0x02 | Secp256k1 |
0x03 | P256 |
0x04 | Reserved — never appears in a canonical EncryptedKeyRecord. BLS12-381 threshold shares share this algorithm-id space conceptually but are encoded through the distinct BlsShareRecord format (magic MKITKSB1, §6.1.2), since a share's plaintext is a variable-length commonware-encoded structure, not a 32-byte scalar. A decoder MUST reject id 0x04 inside a MKITKSV1 record. |
attrs_bits registry (bit 0 = LSB):
| Bit | Meaning |
|---|---|
| 0 | extractable |
| 1 | require_user_presence |
| 2 | device_bound |
Any bit outside 0–2 set MUST be rejected as a decoding error.
Readers MUST reject any version byte other than 0x01 (no dual-version
compatibility — see SPEC-CONVENTIONS §2's note on versioning; this is
local, unreleased state with no installed base to protect) and MUST
reject any trailing bytes after the declared field list, the same
discipline SPEC-INDEX applies to the staging-area file.
Nonce sourcing (normative): the 24-byte nonce is generated fresh,
from a CSPRNG, for every encrypt call — never derived, never a
counter. This is safe without any uniqueness-tracking mechanism because
XChaCha20's 192-bit nonce space makes an accidental collision
astronomically unlikely across any realistic number of key records; a
96-bit (regular ChaCha20/AES-GCM-sized) nonce would not have this
property and would need a counter or record-count cap instead.
AAD construction (normative): the Additional Authenticated Data bound into the AEAD tag — and separately supplied to the OS-native protector wrapping the DEK, though some protectors ignore it (§6.1) — is the concatenation, in this exact order, of:
magic (8 bytes) || version (1 byte)
|| length-prefixed literal "software"
|| length-prefixed label
|| algorithm_id (1 byte) || attrs_bits (1 byte)
|| length-prefixed protector
|| length-prefixed public_key
|| length-prefixed keyidEvery variable-length field is length-prefixed exactly as in the record encoding above, so the AAD has no field-boundary ambiguity — the same canonicalization discipline SPEC-SIGNING §2.1 uses for its domain digests. This AAD binding is what defeats a substitution attack (an adversary swapping one key's ciphertext/wrapped-DEK into another key's record slot, or changing the claimed algorithm/attrs after the fact) — decryption fails closed if any bound field doesn't match what was encrypted.
The encrypted software backend stores BLS12-381 threshold shares (issue #160;
see also §6 of SPEC-RELEASE-THRESHOLD.md). The contract:
MinSig
variant — the wire-encoded
commonware_cryptography::bls12381::primitives::group::Share) so
they cannot ride the generic KeyImporter / KeyExporter paths
(which are pinned at 32 bytes). The keystore exposes a dedicated
BLS-share API on SoftwareKeystore:store_bls_share(label, share_bytes, cohort_public_key,
share_index, threshold, total, keyid, overwrite)load_bls_share(label) -> LoadedBlsSharedelete_bls_share(label)list_bls_shares() -> Vec<(KeyLabel, BlsShareMetadata)>bls_share_metadata(label) -> BlsShareMetadata (public metadata
only, no decryption)BlsShareRecord, a parallel format to
EncryptedKeyRecord with magic MKITKSB1 (canonical
EncryptedKeyRecord uses MKITKSV1). It is XChaCha20-Poly1305
AEAD-encrypted with a DEK wrapped by the same OS-native protector
the canonical record uses.bls12381-thr)u32)u32)u32)<root>/bls12381-thr/<hex(label)>.share.Capabilities { algorithms, .. } of the software backend when
the bls-threshold feature is on. The software-raw backend MUST
NOT advertise BLS — raw storage cannot bind AAD.KeyImporter / KeyExporter / KeyGenerator calls with
algorithm == Bls12381Threshold MUST fail closed with
UnsupportedAlgorithm or UnsupportedOperation. Generation of
BLS shares flows through a separate trusted-dealer ceremony — see
SPEC-RELEASE-THRESHOLD.md §2.1 and mkit-cli's `mkit key
generate --algorithm bls12381-thr --threshold M --total N --label<base>` surface.yubikey, macos-keychain,
linux-secret-service, systemd-creds) MUST reject
Bls12381Threshold with UnsupportedAlgorithm. BLS share storage is
software-backend-only; extending it to native backends with their own
threshold-aware wire formats is out of scope unless this spec is amended.An in-memory backend may be implemented for tests and WASM-like integrations.
Requirements:
BackendKind::Memory.Feature name: backend-macos-keychain.
Default target: macOS only.
Requirements:
mkit key list --backend macos-keychain works.(label, algorithm) values in the normal
sequential case. Concurrent same-label create/import atomicity depends on the
native Keychain primitive exposed by the selected dependency and is not a
cross-process lock guarantee.kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly and
kSecAttrSynchronizable = false is out of scope unless this spec is amended
and the property is covered by capability tests.Windows is not a supported mkit target (MKIT-6; see
docs/INVARIANTS.md): commonware-runtime 2026.9.0's non-Linux
storage-sync path calls libc::sync(), which does not exist on
x86_64-pc-windows-msvc, so the workspace and its test suite no longer
build there. The backend-windows-credential feature, the
WindowsCredentialManager BackendKind variant, and the Windows
Credential Manager implementation described in this section were
removed rather than shipped untested. "windows-credential" is now an
unrecognized backend name (Error::BackendUnavailable, not a
fail-closed platform check). This section number is kept unused, rather
than renumbered, so cross-references to §6.5+ stay stable.
Feature name: backend-linux-secret-service.
Default target: Linux desktop only.
Requirements:
mkit key list --backend linux-secret-service works.(label, algorithm) values in the normal
sequential case. Concurrent same-attribute create/import atomicity depends on
the Secret Service implementation and dependency surface and is not a
cross-process lock guarantee.Feature name: backend-systemd-creds.
Default target: Linux headless/server.
Requirements:
systemd-creds backend may shell out to systemd-creds rather than link
TPM2 libraries.Command with argv tokens.systemd-creds is not available.systemd-creds as encrypted credential storage. It does
not claim TPM/PCR sealing or device binding unless the backend explicitly
requests and verifies those properties.Feature name: backend-yubikey.
Requirements:
mkit-attest and contrib CTAP signer. Fails
closed through the keystore API for FIDO2/CTAP labels and routes users to
the external CTAP signer unless a future keystore signer API can return the
full WebAuthn assertion metadata.Current implementation status:
UnsupportedAlgorithm.can_generate,
can_import, can_export, and can_delete are all false; provisioning is
done out of band with ykman / gpg --card-edit.Capabilities::supports_user_presence is derived from the discovered slot
policies (PinPolicy != Never or TouchPolicy ∈ { Always, Cached }).
supports_device_bound and supports_non_extractable are true iff at least
one signing key was discovered.fido2-/ctap-
prefixed label hits a dedicated guard that returns UnsupportedOperation
(directing callers to the CTAP external signer), while any other label that
does not resolve to an OpenPGP or PIV signing slot returns KeyNotFound.
Both fail closed.External signer and cloud KMS support are out of scope unless this spec is amended.
If implemented:
mkit-rpc signer protocol where practical.CLI and config must use a compact key reference syntax:
<backend>:<label>Examples:
software:default
software-raw:default
macos-keychain:default
linux-secret-service:default
systemd-creds:release
yubikey:mainRules:
BackendKind::as_str for the
authoritative list (software, software-raw, macos-keychain,
linux-secret-service, systemd-creds, yubikey,
external, cloud, memory).Implementation note: the label component of a parsed KeyRef is a
KeyRefLabel, a typed wrapper that re-checks the general label rules in
§5.7 plus an extra reject list ($ ~ * ? [ ] { } ; & | ' "). This is
strictly stronger than the §5.7 validation: labels coming from a key-ref
string cannot contain shell or environment-expansion characters even if a
future backend would accept them in a non-ref context.
The syntax covers the full backend matrix in §6.
A full key ref includes the backend. For signing integrations,
key.<algorithm>_ref = <backend>:<label> is authoritative and must route to
that backend. key.backend is only the default backend for mkit key commands
when no explicit backend or configured key ref supplies one.
mkit-keystore introduces these user-scoped config keys for keystore
selection. Exact names are normative unless amended here:
signer = legacy
key.backend = software
key.default_ref = software:default
key.ed25519_ref = software:default
key.secp256k1_ref = software:default-secp256k1
key.p256_ref = software:default-p256
attest.signer = repo-keyInterpretation:
signer selects the commit-signing source. Built-in default is legacy,
which preserves existing raw-file signing_key behavior. Setting
signer = keystore opts commit signing into mkit-keystore.key.backend is the default backend for mkit key commands when no backend
is provided and no full key ref is being used.key.default_ref is the default key ref for generic commands.attest.signer = repo-key remains the built-in default. Setting
attest.signer = keystore opts attestation signing into the
keystore-backed attestation signer.attest.signer = repo-key and attest.signer = external remain
supported.Precedence:
signer = legacy or empty, commit
signing uses the existing signing_key raw-file behavior. If
signer = keystore, commit signing uses key.ed25519_ref; if empty, it uses
key.default_ref; if both are empty, it fails with a config error rather than
silently falling back to legacy raw-file signing.key.ed25519_ref, key.secp256k1_ref, or key.p256_ref. If the selected
per-algorithm ref is empty, it uses key.default_ref.key.default_ref = yubikey:main selects the YubiKey backend for signing; it
must not be silently reinterpreted as software:main because
key.backend = software is also set.Every config key in section 8.1 must be present in REPO_FORBIDDEN_KEYS in
rust/crates/mkit-cli/src/config.rs. The REPO_FORBIDDEN_KEYS list may
include additional keys (for example signing_key,
attest.external_signer_path, attest.external_signer_args,
attest.secp256k1_key_path, attest.p256_key_path,
attest.default_algorithm) covering legacy signer selectors and other
private-key-influencing settings. Such extras must not be removed because
they enforce the same confused-deputy posture for the pre-keystore code path.
Reason:
Existing keys remain valid:
signing_key
attest.secp256k1_key_path
attest.p256_key_path
attest.external_signer_path
attest.external_signer_argsCompatibility behavior:
signing_key continues to drive legacy raw-file Ed25519 commit signing.attest.*_key_path continues to drive legacy repo-key attestation signing.The new command namespace is:
mkit key <subcommand>mkit keygen remains as a legacy compatibility command and may internally
delegate to the old raw-file implementation.
mkit key generateUsage:
mkit key generate [--backend <backend>] [--label <label>]
[--algorithm ed25519|secp256k1|p256]
[--extractable|--non-extractable]
[--device-bound]
[--require-user-presence]
[--force]
[--print-pubkey]Defaults:
--backend: key.backend, fallback software.--label: parsed from the algorithm-specific configured key ref; if no ref
is configured, defaults to default for Ed25519, default-secp256k1 for
secp256k1, and default-p256 for P-256.--algorithm: ed25519.extractable: backend default. Software backend defaults to extractable.device_bound: false unless requested.require_user_presence: false unless requested.Behavior:
Keystore::generate with GenerateOptions { overwrite: false }
by default.Keystore::generate with GenerateOptions { overwrite: true }
when --force is provided.--force is provided.--print-pubkey is provided, must print the canonical key ID on a stable
final line suitable for scripting.mkit key listUsage:
mkit key list [--backend <backend>] [--json]Behavior:
mkit key importUsage:
mkit key import --algorithm ed25519|secp256k1|p256
[--backend <backend>] [--label <label>]
(--hex <64-hex> | --file <path>)
[--extractable|--non-extractable]
[--device-bound]
[--require-user-presence]
[--force]Behavior:
Keystore::import with ImportOptions { overwrite: false } by
default.Keystore::import with ImportOptions { overwrite: true } when
--force is provided.--force is provided.mkit key exportUsage:
mkit key export [--backend <backend>] [--label <label>]
[--algorithm ed25519|secp256k1|p256]
--unsafe-print-secretBehavior:
--unsafe-print-secret when
printing secret material to stdout.mkit key deleteUsage:
mkit key delete [--backend <backend>] [--label <label>]
[--algorithm ed25519|secp256k1|p256]
--yesBehavior:
--yes. No interactive prompt is required.Current commit signing lives in mkit-core::sign and signs Ed25519 over the
current domain-separated commit signing hash.
Requirements:
mkit-core continues to own canonical commit/remix signing bytes and
verification.mkit-core must expose enough public helpers to support keystore signing
without depending on mkit-keystore.mkit_core::sign::commit_signing_hash(commit), a 32-byte digest currently
defined as BLAKE3(len_le16(COMMIT_DOMAIN) || COMMIT_DOMAIN ||
commit_signing_bytes(commit)).KeyPair signing for the same seed and same commit object.Commit.signer.Implementation options:
mkit-cli that obtains the commit signing
hash through mkit_core::sign::commit_signing_hash, asks the Ed25519
keystore signer to sign exactly those 32 bytes, then stores the resulting
64-byte signature in Commit.signature.domain_digest construction in mkit-cli or
mkit-keystore.docs/specs/SPEC-SIGNING.md describes BLAKE3(len_le16(domain) || domain ||
signing_bytes), matching the implementation — see SPEC-SIGNING §3 for the
canonical formula and mkit_core::sign::domain_digest for the implementation.
Current attestation signing uses mkit_attest::Signer:
pub trait Signer {
fn algorithm(&self) -> Algorithm;
fn keyid(&self) -> Result<String, Error>;
fn sign(&mut self, pae: &[u8]) -> Result<Vec<u8>, Error>;
}Requirements:
mkit_attest::Signer trait.repo-key and external signers remain supported.attest_factory must accept attest.signer = keystore only from user-scoped
config or CLI arguments.mkit_attest::verify registry when the trust root contains the emitted
public key.Key ID compatibility:
ed25519:<pubkey>.blake3:<hash(pubkey)>.blake3: for Ed25519.mkit-rpc already defines a signer protocol with KEY_FORM_OPAQUE_HANDLE.
mkit-keystore does not replace this protocol.
Rules:
mkit-keystore may later provide an external signer backend or bridge using
the same RPC protocol.KEY_FORM_OPAQUE_HANDLE and explicit key_ref bytes.REPO_FORBIDDEN_KEYS.commit, attest, merge, cherry-pick, or any future signing command
must not generate a key as a side effect.mkit key generate or legacy
mkit keygen.Audit logging is not required.
If implemented:
mkit-keystore must test:
mkit-cli must test:
mkit key generate creates a key through the software backend.mkit key list reports generated keys.mkit key import imports a known Ed25519 seed.mkit key export --unsafe-print-secret round-trips an imported extractable
key.mkit key delete --yes deletes exactly the selected key.Required golden tests:
mkit_core::sign::KeyPair for the same commit.Secp256k1Signer for the same PAE.P256Signer for the same PAE.Clarification:
Each OS-native backend must include tests gated by target OS and feature flag.
Tests must not require developer-specific keychain state. They must create and delete unique test labels.
Live OS-native backend tests are ignored by default and must fail loudly when
explicitly invoked without MKIT_RUN_NATIVE_KEYSTORE_TESTS=1. CI jobs that
claim native backend coverage must run those ignored tests with the required
environment gate set.
Hardware tests may be ignored by default and documented as manual tests.
mkit-keystore and its integrations must pass:
cd rust
cargo fmt --check
cargo clippy --all-targets --all-features --workspace -- -D warnings
cargo build --locked --workspace
cargo nextest run --locked --workspace --all-features
cargo test --locked --workspace --docIf all-features cannot include OS-native backends on every CI OS, features must be structured so unsupported target-specific code is cfg-gated correctly.
Status tags below describe what is in the current build. They are advisory; the per-section requirements above remain normative.
mkit-keystore crate. Shipped.BackendKind::Memory is
defined but no MemoryKeystore implementation exists. Software-raw with
a tempfile-backed root is used in the test suite instead.SoftwareRawKeystore, which delegates to
mkit_core::sign::{load_raw_32, save_raw_32, save_raw_32_create_new}.commands/key.rs. Shipped at
rust/crates/mkit-cli/src/commands/key.rs.mkit key in mkit-cli/src/lib.rs. Shipped.mkit keygen. Shipped.KeyConfig and
REPO_FORBIDDEN_KEYS in mkit-cli/src/config.rs.mkit_attest::Signer adapter. Shipped as
KeystoreAttestSigner in commands/attest_factory.rs.attest_factory for attest.signer = keystore. Shipped.mkit-core helper needed for keystore commit
signing without creating a dependency cycle. Shipped —
mkit_core::sign::commit_signing_hash is exposed and used by
CommitSigner::Keystore in commands/commit.rs.mkit commit to use a configured Ed25519 keystore key ref.
Shipped behind signer = keystore.keystore_commit_signature_matches_legacy_keypair_signature in
commands/commit.rs tests.software the encrypted-at-rest software backend using OS-protected
envelope encryption. Shipped — SoftwareKeystore + EncryptedKeyRecord
(XChaCha20-Poly1305, length-prefixed AAD, OS protector wrapping the DEK).software-raw. Shipped as
SoftwareRawKeystore.systemd-creds, and
YubiKey OpenPGP/PIV/FIDO2 backends behind feature flags with honest
capabilities. Shipped for macOS Keychain (macos-keychain), Linux
Secret Service (linux-secret-service), systemd-creds
(systemd-creds), and YubiKey OpenPGP + PIV (backend-yubikey).
Not shipped: a dedicated YubiKey FIDO2/CTAP routing in this backend
— FIDO2 stays on the external signer path per §6.7. Removed:
the Windows Credential Manager backend (§6.4) — Windows is not a
supported target (MKIT-6).open_backend plus selection_for
in commands/key.rs, mirrored in commit and attest paths.MKIT_RUN_NATIVE_KEYSTORE_TESTS=1 per §14.4; not every CI runner
currently exercises every backend.docs/THREAT-MODEL.md does not yet document per-backend
malware, disk-extraction, backup-exfiltration, and side-channel assumptions
for every backend in §6.mkit-keystore includes macOS Keychain, Linux Secret Service,
systemd-creds, and YubiKey backends with capability
reports limited to what they actually implement.software backend is encrypted-at-rest; raw compatibility is explicit via
software-raw.key.*_ref defaults; attestation-only key refs are out of
scope unless a future need is identified and this spec is amended.Open question: whether a future mkit key export --file <path> mode is
worth adding. Out of scope for now.
mkit-keystore uses these fixed conservative defaults:
software)software-rawsigning_keyrepo-keyed25519:<pubkey>blake3:<hash(pubkey)>--unsafe-print-secretkey.*_ref values§2 fixes the design-level invariants and §13 the security requirements; this table indexes them (plus the backend rules of §5–§6) by the property that MUST always hold. Rows cite the normative text; nothing here adds a new requirement.
| Invariant | Enforced by |
|---|---|
| A repo can never choose the key, backend, label, signer, or algorithm that signs | every §8.1 selector (and the §8.3 legacy ones) is in REPO_FORBIDDEN_KEYS (§2.5, §8.2, §13.1) |
| No signing command ever creates or rotates a key | explicit mkit key generate / legacy mkit keygen only; missing keys are hard errors (§2.6, §10, §13.2) |
| An unavailable backend never silently degrades to a weaker one | construction/operations return unavailable errors; Linux protector selection fails closed (§5.3, §6.1) |
| Capabilities never overstate what a backend can do | operation booleans tied to trait availability; per-backend honesty rules (§2.7, §5.3, §13.4) |
| Requested key attributes are honored or refused, never silently weakened | backends reject unsupported attribute combinations (§5.2, §13.4) |
| Secret bytes never leak via Debug, logs, or implicit paths | Zeroizing storage, manual Debug, expose_secret naming, redacting Display (§5.5, §5.8, §13.3) |
| Export happens only explicitly and only for extractable keys | --unsafe-print-secret gate; typed non-extractable error; non-exportable backends omit KeyExporter (§5.7, §9.4, §13.3) |
software keys are encrypted at rest under an OS protector, never a password | per-record DEK + AEAD, OS-wrapped, fail closed with no usable protector (§2.10–2.11, §6.1) |
| An encrypted record cannot be re-bound to different metadata | AAD binds version/backend/label/algorithm/pubkey/keyid/attrs; BLS AAD additionally binds cohort pubkey + share index + M + N (§6.1, §6.1.1) |
(label, algorithm) is never silently overwritten, and delete removes exactly one key | overwrite requires --force / overwrite: true; no prefix deletes (§5.7, §9.1, §9.5) |
| A configured full key ref's backend is authoritative | the ref routes to its backend; never reinterpreted via key.backend (§7, §8.1) |
| Keystore signing is byte-equal to legacy signing where determinism allows | golden equivalence vectors for deterministic software backends; verification-equivalence otherwise (§2.8, §14.3) |
| The keystore never invents protocol-level domain separation | sign signs the supplied bytes under existing mkit semantics (commit hash per §10, DSSE PAE per §11) (§5.6) |
| FIDO2/CTAP labels fail closed through the keystore API | dedicated label guard returns UnsupportedOperation, routing to the external signer path (§6.7) |