Skip to content

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 -chain are 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:

secsy-ca ca import -label acme-from-p12 -key legacy-root.p12 -pass-file p12.pass

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:

secsy-ca import-key -label legacy-tsa -key tsa.key -role tsa

-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:

secsy-secret signing-key import -name release-signing -key app-signing.key -out pub.pem

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:

  1. 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-imported flag (and attestation_allow_imported_keys) exists for exactly the migrated-CA case, and it should be scoped to that CA rather than turned on globally.
  2. 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