Importing existing keys and adopting an existing CA¶
Moving key material you already have under this PKI, without re-keying.
Every other path in secsy-pki generates keys: a key is born inside the key provider and its private half never exists anywhere else. That is the property the whole design defends, and it is the right default.
It is also unavailable to anyone who already runs a CA. A root whose certificate sits in laptops, phones, switches, and a build system nobody remembers configuring cannot be re-keyed on a Tuesday — re-keying means redistributing the trust anchor to everything that has it. The same is true of an application signing key whose public half is compiled into clients that have already shipped.
So secsy-pki supports import: taking key material that exists in a file and placing it in the key provider, where the rest of its life is HSM-held, audited, monitored, and gated like any other key. What import cannot do is change where the key came from — and the tooling says so, at every step, rather than letting a successful command imply otherwise.
| Command | Does |
|---|---|
secsy-ca ca import |
Adopt an existing CA: import its private key and install its existing certificate, producing an ordinary, issuing CA record |
secsy-ca import-key |
Place an existing private key into the key provider under a label, on the CA, TSA, or signing backend |
secsy-secret signing-key import |
Adopt an existing application signing key into the named-signing-key registry |
The shell is the preferred place to run all three. Their input is raw private key material, and the fewer copies of it that travel anywhere the better: read it once, from a local path, on an operator's shell.
All three are nonetheless reachable from the console (Task 198): the
Authorities view carries an Adopt an existing CA panel for
secsy-ca ca import and an Import a key into the provider panel for
secsy-ca import-key, and the Secrets view an Import an existing signing
key panel for secsy-secret signing-key import. Each is labelled as carrying a
private key, size-limits the body before decoding it, and echoes no part of the
material back — and ca import can instead name a key that is already on the
provider, which moves no key material at all. See the
CLI ↔ console parity matrix.
Adopting an existing CA¶
The migration command. It imports the key, validates the certificate against it, and persists an active CA record that issues, revokes, rotates, and publishes exactly like a CA created here.
secsy-ca ca import \
-label acme-legacy-root \
-key /secure/legacy-root.key \
-pass-file /secure/legacy-root.pass \
-cert /secure/legacy-root.crt \
-chain-out chain.pem
Adopted existing root CA "acme-legacy-root":
ID: 5a763454-a640-4be8-90f1-cb270aa50e7a
Subject: CN=Acme Legacy Root CA,O=Acme Corp
Serial: 205680955909635647595802196839725969470470860684
Validity: 2026-08-25T20:36:04Z — 2036-08-22T20:36:04Z
Key: pkcs11:token=secsy-pki-root;object=acme-legacy-root;type=private (ecdsa-sha2-nistp384)
Imported: key material read from legacy-root.key (pkcs8-encrypted) and written into the provider
Verified: the provider signed a challenge with the key and it matches the certificate
From that point the CA is unremarkable:
secsy-ca issue -ca acme-legacy-root -profile server -csr svc.csr -out svc.crt
openssl verify -CAfile legacy-root.crt svc.crt # svc.crt: OK
The certificate it just issued verifies against the root certificate the world already trusts. That is the entire point: nothing downstream had to change.
What is checked, and why¶
An adoption that binds a CA record to the wrong key produces an authority that looks healthy and signs certificates nothing can verify — discovered at the worst possible moment. Every check below is therefore fail-closed:
| Check | Rejected because |
|---|---|
| The private key's public half equals the certificate's public key | Otherwise the CA record points at a key that cannot have issued anything under that certificate. Checked before anything is written, so a mismatch never strands key material on the token |
basicConstraints present with cA=TRUE |
A leaf certificate is not a CA |
keyUsage includes keyCertSign (when keyUsage is present) |
Certificates issued under it would not verify |
| The certificate is currently valid | An expired CA cannot issue; re-certify the key first |
| A self-signed certificate verifies under its own key | Catches a truncated or mispasted root before it becomes a trust anchor no path can be built through |
| The key passes the key-quality gate | A ROCA-vulnerable or blocklisted key is not rehabilitated by moving it into an HSM — the HSM cannot un-factor it |
| The provider can actually sign with the key | Proved with a real signature over a random challenge, verified against the expected public key, before the CA record is persisted |
Deviations that do not break issuance are reported as warnings and the
import proceeds: a missing cRLSign or digitalSignature key usage, a
certificate expiring within 90 days, a missing keyUsage extension entirely.
Where the parent goes¶
For a subordinate CA the issuer is resolved automatically:
- If a CA already in this PKI has the matching subject and its certificate
verifies the signature, the adopted CA is linked to it (
parent_id). Chain serving, rotation, and revocation then walk the real tree. Adopt the root first and this happens on its own. - Otherwise the certificates supplied via
-chainare recorded as external chain material, exactly as in the externally-signed subordinate CA flow, so the served chain still reaches the external trust anchor. -parent <id|label>names the parent explicitly; the certificate must genuinely verify under it.
Input formats¶
-key accepts what operators actually have:
| Format | Typical origin |
|---|---|
PKCS#8 PRIVATE KEY |
openssl genpkey, openssl genrsa (OpenSSL 3) |
PKCS#8 ENCRYPTED PRIVATE KEY (PBES2 + PBKDF2, AES-CBC or 3DES) |
openssl genpkey -aes256, openssl pkcs8 -topk8 |
PKCS#1 RSA PRIVATE KEY / SEC1 EC PRIVATE KEY |
openssl genrsa (OpenSSL 1.x), openssl ecparam -genkey |
Legacy Proc-Type: 4,ENCRYPTED PEM (DEK-Info) |
pre-3.0 openssl genrsa -aes256 — still guarding plenty of long-lived roots |
OPENSSH PRIVATE KEY, optionally bcrypt-encrypted |
ssh-keygen |
PKCS#12 / .p12 / .pfx |
Windows CA export, browser export — carries the certificate too |
| Bare DER (PKCS#8 / PKCS#1 / SEC1) | Appliance exports |
A .p12 supplies key and certificate, so -cert becomes optional:
Passphrases are read from -pass-file <file> (- reads stdin) or the
SECSY_KEY_PASSPHRASE environment variable. There is deliberately no -pass
flag: a passphrase on the command line lands in the shell history and the
process table.
Adopting a key that is already in the provider¶
When the key was placed on the token out of band — by a vendor migration tool, a
wrapped restore, or an earlier import-key — adopt it by label instead. Nothing
is written to the backend; the key is only verified to match the certificate and
to be usable:
secsy-ca import-key -label legacy-root-key -key legacy-root.key -pass-file pass.txt
secsy-ca ca import -label acme-legacy-root -existing-key legacy-root-key -cert legacy-root.crt
Four-eyes¶
Adopting a CA creates one, so it passes through the same
maker-checker gate as init-root and
issue-intermediate (ca.create class) when approvals.enabled is set. An
authority appearing in the tree without a second signature is precisely what
that gate exists to prevent.
Key material the backends accept¶
Format is one question, the key itself is another. Every import — ca import,
import-key, and the secret layer's signing-key import — runs the same
checks on the key material before any backend is touched.
| Algorithm | Accepted |
|---|---|
| RSA | 2048, 3072, or 4096 bits, public exponent 65537 |
| ECDSA | P-256, P-384, P-521 |
| Ed25519 | yes |
| ML-DSA (post-quantum) | no — no backend here accepts foreign ML-DSA material |
The RSA sizes are matched exactly, not rounded to the nearest name. A
2560-bit key is refused rather than recorded as rsa-3072: that string ends up
on the CA record and in every inventory and compliance report derived from it,
where it would overstate the modulus forever. The three sizes are also precisely
what a YubiHSM 2 holds, so there is nowhere for a fourth to go.
On top of that, imported key material passes the same key-quality gate that subject public keys pass before they are certified — ROCA (CVE-2017-15361), exponent policy, and modulus sanity. Moving a broken key into an HSM does not repair it; the HSM cannot un-factor a ROCA modulus, and the one command whose purpose is to give a key a better home should not be the one that launders it.
Importing RSA onto a YubiHSM¶
A YubiHSM 2 is stricter than a software token, and it is terse about it. It
implements exactly rsa2048, rsa3072 and rsa4096, and exactly one public
exponent. Anything else comes back as a single undifferentiated
CKR_ATTRIBUTE_VALUE_INVALID — the same code for "wrong size", "wrong
exponent" and "unsupported algorithm" — after a round trip over USB, which
tells an operator holding a valid-looking key file nothing at all.
So both constraints are checked on the host, and the rejection says which one was hit:
$ secsy-ca import-key -label legacy-root -key odd.key
error: keyprovider: import: RSA key is 2560 bits; only 2048, 3072, or 4096 are
supported (an HSM holds exactly these sizes, and this PKI has no
key-type name for any other)
$ secsy-ca import-key -label legacy-root -key e3.key
error: keyprovider: the key fails the key-quality gate and must not be imported:
RSA public exponent 3 is below the required minimum of 65537
What does work is the ordinary case, and it is fast — the device stores primes rather than searching for them, so import takes a second or two where generating an RSA-4096 key on the same hardware takes minutes:
| Key | Import | Generate (for comparison) |
|---|---|---|
| RSA-2048 | ~0.9 s | ~5 s |
| RSA-3072 | ~1.8 s | ~40 s |
| RSA-4096 | ~2.3 s | minutes |
(Measured on a YubiHSM 2, firmware 2.4.0, over direct USB.)
All of this is exercised against real hardware by the
YubiHSM conformance suite — the three sizes
round-tripped through PKCS#11 and signing with both PKCS#1 v1.5 and PSS, a
decrypt-only RSA KEK unwrapping, the requested CKA_ID being honoured, the
attestation reporting the imported key honestly, and each rejection above
arriving as a sentence rather than as a status code.
One thing to check before blaming the key file: which yubihsm_pkcs11.so is
being used. Yubico's module before 2.7.2 cannot create an RSA key at all
through a least-privilege template — it reads CKA_UNWRAP = FALSE as "this is a
wrap key" and creates one, which PKCS#11 then cannot find as a private key.
secsy-pki detects those releases and adapts the template, so import works
anyway, but bookworm-backports ships 2.6.0 and a mounted host module may well
be it. The -yubihsm container image builds 2.8.0 from source; see
if you supply your own module.
From the container¶
The same command, run as the image's non-root user with the device passed
through, is what scripts/yubihsm-container-test.sh exercises against real
hardware — every encoding above, at every supported size:
docker run --rm --device "$dev" --group-add "$(stat -c %g "$dev")" \
-v "$PWD/config.yaml:/etc/secsy/config.yaml:ro" -v "$PWD/keys:/keys:ro" \
-e SECSY_YUBIHSM_PASSWORD -e SECSY_USER_PIN -e SECSY_KEY_PASSPHRASE \
--entrypoint secsy-ca ghcr.io/blechschmidt/secsy-pki:latest-yubihsm \
-config /etc/secsy/config.yaml import-key -label legacy-ca -key /keys/legacy-ca.pem
Two container-specific details. The key file is mounted read-only and read
once: nothing writes it back, and nothing about the import puts it in an image
layer. And the passphrase for an encrypted key arrives as SECSY_KEY_PASSPHRASE
or through a mounted -pass-file, never as a flag — which is the same rule as
on a host, for the same reason, and happens to be the only shape a container
runtime makes convenient anyway.
Importing a bare key¶
secsy-ca import-key is the building block — a TSA key, an artifact-signing
key, an SSH CA key, or staging a CA key for ca import -existing-key:
-role (ca | tsa | signing) selects which backend receives the key,
since those roles may resolve to different providers; an unknown role is
rejected rather than quietly falling back to the CA backend. -usage decrypt
imports an RSA key-encryption key for the envelope layer
instead of a signing key.
For a signing key the command proves the provider can sign with it — a real signature over a random challenge, verified against the key's own public half — before reporting success. A decrypt-only key cannot sign and is exempt.
Importing an application signing key¶
The secret-layer counterpart, for a key whose public half is already embedded in shipped clients:
The algorithm is derived from the key for ECDSA and Ed25519. RSA needs an
explicit -algorithm, because the same key can be used with PSS or PKCS#1
v1.5 and the choice must match whatever the existing verifiers already do —
guessing would produce signatures nothing accepts.
Continuity is the whole point, and it holds end to end: after the import, a
signature produced on the HSM verifies with plain openssl against the
application's original public key file.
secsy-secret sign -key release-signing -in payload.txt -out payload.sig
openssl pkeyutl -verify -pubin -inkey app-signing.pub -rawin -in payload.txt -sigfile payload.sig
# Signature Verified Successfully
What import does not give you¶
Protection: yes. An imported key is stored with exactly the attributes a
generated key gets — CKA_SENSITIVE, CKA_EXTRACTABLE=false, CKA_PRIVATE,
and a single purpose (sign or decrypt, never both). It cannot be wrapped
back off the device. On a high-availability multi-token set it is imported onto
every member, so failover does not turn into an outage; if any member rejects
it, the error names the tokens that already hold the key so the split is
resolved deliberately rather than discovered under load.
Provenance: no, and it cannot. The key existed outside the provider before it arrived. Two consequences that no amount of tooling can undo:
- Hardware attestation reports it as imported. A YubiHSM's own key
attestation records the origin of every key, and secsy-pki's
attestation verifier surfaces it. A policy that
requires generated-on-device keys will fail an imported one — correctly. The
-allow-importedflag (andattestation_allow_imported_keys) exists for exactly the migrated-CA case, and it should be scoped to that CA rather than turned on globally. - Every copy made before the import is still a copy. Backups, the laptop it was generated on, the ticket someone attached it to. The import moves the authoritative key; it does not reach the others. Destroying them is an operator task, and the CLI ends with that reminder.
A related consequence for the remotely verifiable audit log:
the argument that a key signed nothing outside the published record rests on the
key never having left the device. For an imported key that argument starts at
the import, not at key generation. The key.import audit event is the marker of
where it starts.
On the software keystore, import protects nothing. The software provider stores keys as files; importing into it moves the key, it does not secure it. The command says so, and adoption onto it emits a warning. Configure a PKCS#11 backend for production CA keys.
Backends that cannot import¶
The cloud-KMS backends (AWS KMS, Azure Key Vault, GCP KMS, Vault Transit) do not implement import: bringing your own key there is a service-specific wrapped- import ceremony against the provider's own API, not a key-material upload. The command reports that plainly rather than pretending:
error: keyprovider: this backend cannot import an existing key (backend "kms");
generate the key instead, or use the backend's own bring-your-own-key procedure
Audit trail¶
| Event | Recorded on |
|---|---|
key.import |
Any key placed into a provider — detail carries backend, key type, source file format, usage, and whether the signing self-check passed |
ca.import |
A CA adopted — detail carries subject, serial, self-signed, whether key material was written, the key's SPKI fingerprint, and any warnings |
secret.signing_key_import |
An application signing key adopted — algorithm, key id, source format |
These are the provenance record. A key with a key.import event in its history
is a key that lived outside the provider first — which is exactly what hardware
attestation independently reports.
Serial numbers after adoption¶
End-entity serials are random 128-bit values, so nothing the adopted CA issues from now on can collide with what it issued before. Subordinate-CA serials come from a per-CA counter that starts fresh, which can collide with an intermediate the legacy deployment issued from a low sequential counter. The import warns about this; revoke or retire those intermediates before issuing new ones under the adopted CA.
↩ Back to the CA section · documentation map