NovaSeal Key Management Guide

Status: Works today (ADR-0041, NovaSeal v0.1+)

This document covers the full key lifecycle for NovaSeal: generation, deployment, rotation, compromise recovery, and multi-region availability.


1. Key Types and Algorithms

NovaSeal's default local signing profile signs capsules with ECDSA P-256 / SHA-256 (DSSE); the cloud-KMS profiles (aws_kms, azure_kv, gcp_kms) use the same P-256 curve — this is hardcoded, not a choice per deployment: AwsKmsSigningBackend always requests SigningAlgorithm="ECDSA_SHA_256", AzureKvSigningBackend always requests SignatureAlgorithm.es256, and GcpKmsSigningBackend expects an EC P-256 asymmetric key — a key created with any other curve or algorithm (e.g. Ed25519) will fail to sign through these backends. The local profile is broader: it signs with ECDSA P-256 (the default, generated by nova seal init) or Ed25519 (RFC 8032), given a matching certificate. Ed25519 is also the Evidence Bundle / collector signer (evidence/signing.py). ECDSA P-256 was chosen for the seal core because:

Each of these four signing profiles has:

Two further signing paths exist outside the local/aws_kms/azure_kv/ gcp_kms profile system in novaseal.yaml — see §2.3 and §2.4. Only Sigstore has a CLI flag (nova seal sign --backend sigstore); the x509 identity is a library API only:


2. Key Generation

2.1 Cloud KMS-backed (production)

There is no on-premises HSM backend today. NovaSeal has no PKCS#11 or YubiHSM integration; the only non-file profiles are aws_kms, azure_kv and gcp_kms. A key that cannot leave an HSM cannot be used by the local profile, which reads a PEM file. On-premises HSM signing is future design.

The examples below generate an ECDSA P-256 key — the only algorithm the cloud KMS profiles sign with (§1).

GCP Cloud KMS (recommended for GCP deployments):

gcloud kms keys create novaseal-production \
  --location us-central1 \
  --keyring novafabric \
  --purpose asymmetric-signing \
  --default-algorithm ec-sign-p256-sha256

AWS KMS (recommended for AWS deployments — this is what the aws_kms profile actually talks to; AWS CloudHSM is a separate product and is not the backend AwsKmsSigningBackend calls):

aws kms create-key \
  --key-spec ECC_NIST_P256 \
  --key-usage SIGN_VERIFY

2.2 Software Key Store (Development / Air-gapped Minimum)

For development, on-premises and air-gapped environments (the local profile):

# Generate an ECDSA P-256 private key (PKCS#8 PEM) and a self-signed certificate
# (this is the same procedure as the local profile in the Configuration Reference §2.1)
openssl ecparam -name prime256v1 -genkey -noout | \
  openssl pkcs8 -topk8 -nocrypt -out ~/.novafabric/keys/seal.key

openssl req -new -x509 -key ~/.novafabric/keys/seal.key \
  -out ~/.novafabric/keys/seal.crt \
  -days 3650 -subj "/CN=NovaSeal-Local"

# This produces:
#   ~/.novafabric/keys/seal.key   (ECDSA P-256 private key — PROTECT THIS FILE)
#   ~/.novafabric/keys/seal.crt   (X.509 certificate — reference from cert_path)

Security requirements for software key storage:

2.3 x509 cert-pinned identity (ADR-0055) — works today, library API only

trust/novaseal/x509_identity.py (shipped v0.91.0) is a distinct signing identity from the four profiles above: it signs with a long-lived ECDSA P-256 or RSA-2048+ key, embeds the operator-issued X.509 certificate alongside every signature, and verifies offline with no CA path-building — trust is a pinned set of certificate SHA-256 fingerprints the operator maintains, not a certificate chain to a root. This is deliberately air-gap-friendly.

from novafabric.trust.novaseal.x509_identity import (
    X509SigningIdentity, verify_x509_signature,
)

identity = X509SigningIdentity.from_pem(key_pem, cert_pem)
sig = identity.sign(payload)                     # X509Signature
result = verify_x509_signature(
    payload, sig, pinned_fingerprints={identity.certificate_fingerprint},
)
# result.valid, result.reason, result.subject_common_name

Key generation is the same openssl ecparam/openssl req -x509 procedure as §2.2 (or an RSA-2048+ equivalent). from_pem refuses any other key — an EC key on another curve (P-384, P-521, secp256k1), RSA below 2048 bits, Ed25519 — and a certificate whose public key is not the private key's, with X509IdentityError. verify_x509_signature likewise fails a signature whose certificate key is not P-256 or RSA-2048+. Before 2026-10-22 a P-384 key was accepted and its signatures were labelled ecdsa-p256-sha256. Rotation and compromise recovery follow the same procedures as §3/§4 — the trust anchor to update is your pinned_fingerprints set, not a CA.

Honest limits: this is a working library primitive with passing tests (tests/seal/test_x509_identity.py), not yet wired into novaseal.yaml or any nova seal signing flag. The Rekor inclusion-proof option is future design.

CA-bundle chain validation (ADR-0055 trust step 2) — experimental

As an alternative to pinning every leaf, the operator can trust a CA bundle (concatenated PEM). Trust is resolved "pinned OR chain", pinned first: a pinned certificate is accepted as before; otherwise the embedded certificate must chain to a bundle anchor via RFC 5280 path validation (cryptography's x509.verification, fully offline).

from novafabric.trust.novaseal.x509_identity import (
    load_ca_bundle, verify_x509_signature,
)

anchors = load_ca_bundle(Path("/etc/novaseal/ca-bundle.crt").read_bytes())
result = verify_x509_signature(
    payload, sig,
    ca_bundle=anchors,                 # every bundle cert is a trust anchor
    intermediates=[intermediate_cert], # untrusted, for path building only
    # validation_time=<datetime>,      # default: now (UTC)
)
# result.trust_basis == "ca_chain"; result.chain_subjects == [leaf, ..., anchor]

validate_certificate_chain(leaf, anchors, ...) is also exposed on its own. The same check runs on the CLI as nova verify --ca-bundle <pem> (or the optional ca_bundle: key in novaseal.yaml) against the certificate embedded in the capsule's DSSE envelope — see CLI reference. On the CLI the chain check is bound to the signature (verify_dsse_signer_chain): it passes only when some DSSE signature entry verifies over the signed bytes under the public key of its own embedded certificate, and that same certificate chains to the bundle. An entry whose pubkey differs from its cert's key, or that carries no cert, never counts — so a forged envelope cannot borrow a legitimately issued leaf.

Policy: every certificate in the path must be inside its validity window at the validation time, issuer signatures must verify, CA certificates must meet the WebPKI CA extension profile (basicConstraints CA, keyCertSign, path length); the signing (leaf) certificate must not be a CA but needs no particular EKU or subjectAltName. Path search is bounded (8 intermediates by default, 16 max).

Because no EKU is required, any end-entity certificate issued under a bundle anchor can sign capsules that verify — a TLS server certificate from the same CA included. Put a dedicated signing CA (or a signing-only intermediate) in the bundle, not a general-purpose enterprise root.

Offline CRL revocation check (ADR-0070 §3, ADR-0055 OQ-55-3) — experimental

Air-gapped nodes cannot reach a CRL distribution point, so revocation is checked against CRLs an operator syncs into a local directory (a cron job on a connected host copying each CA's CRL, DER or PEM, into e.g. /var/lib/novaseal/crl/). NovaFabric never fetches a CRL.

nova verify --ca-bundle /etc/novaseal/ca-bundle.crt \
    --crl-dir /var/lib/novaseal/crl [--crl-strict] path/to/capsule/
from novafabric.trust.novaseal.crl import load_crl_directory
store = load_crl_directory(Path("/var/lib/novaseal/crl"))
result = validate_certificate_chain(leaf, anchors, crl_store=store)  # crl_strict=False
# result.revocation.certificates -> per-cert status, leaf first

Every certificate on the validated path below the terminating anchor (leaf and intermediates) is checked against the CRL issued by its issuer, and the CRL's signature must verify under that issuer's public key (the issuer must also permit cRLSign when it carries keyUsage). When you bundle root + intermediate (the CLI needs the intermediate in the bundle), path building stops at the intermediate; the check then walks one step further to a bundle certificate that directly signed it, so the root's CRL can revoke the intermediate. A self-signed root is never revocation-checked — remove it from the bundle to distrust it.

Status Meaning Default (soft-fail) --crl-strict
good a current, authentic CRL does not list the serial pass pass
revoked an authentic CRL lists it with revocation date ≤ validation time — or reason keyCompromise/cACompromise (retroactive, any date), or invalidityDate ≤ validation time (reason shown) fail fail
no_crl no CRL from the issuer covers the certificate warning fail
stale only CRLs outside thisUpdate ≤ now ≤ nextUpdate warning fail
invalid_crl CRLs name the issuer but none verifies (forged/unsigned) warning fail

Warnings are always printed per certificate under Revocation (CRL, offline, soft-fail). A forged CRL can neither revoke nor clear a certificate. Revocation is irreversible, so an authentic but stale CRL that lists the serial still yields revoked (a certificateHold counts only from the newest current CRL). The directory read is bounded: at most 256 candidate files (more fails closed — never a silently truncated set) and 16 MiB per file (larger is skipped with a finding); hidden files (e.g. a sync job's partial download) and sub-directories are ignored; non-CRL files, delta CRLs, indirect / reason-partitioned CRLs and CRLs with an unknown critical extension are skipped and reported as skipped <file>: <why>. CRLs whose IssuingDistributionPoint scope (user-only, CA-only, distribution-point name) does not cover a certificate are not used for it. novaseal.yaml accepts crl_dir: and crl_strict: equivalents.

Limits: no OCSP (ADR-0070 §6), no delta-CRL merge, no indirect CRLs, CLI evaluates the signer chain at the current time. The RFC 3161 TSA chain (§2.5) uses the same directory: revocation is evaluated as of the token's genTime, but CRL freshness (stale) is judged at verification time — so a CRL synced today works with --crl-strict.

Honest limits (experimental): revocation is CRL-only and needs an operator sync job (above), the CLI validates at the current time (a signer certificate that has since expired fails; the library's validation_time lets a caller validate at signing time instead), the DSSE envelope carries only the leaf so any intermediate must be in the bundle for the CLI, and the mixed-mode verify.trust_bundles list from the ADR is not implemented.

2.4 Sigstore keyless (ADR-0071) — works today via nova seal sign --backend sigstore

No persistent private key to generate, distribute, or rotate: nova seal sign <manifest> --backend sigstore obtains a short-lived OIDC identity token, exchanges it for an ephemeral Fulcio certificate, signs with the ephemeral key, and submits the certificate + signature to Rekor for an inclusion proof. Requires pip install novafabric[sigstore]. Verify with nova verify --backend sigstore --capsule-id <id>.

Because there is no long-lived key, none of the rotation/compromise/ multi-region guidance in §3–§5 applies to this path — the "key" is a fresh Fulcio certificate per signature, scoped to the signer's OIDC identity instead.

2.5 RFC 3161 TSA trust chain (ADR-0070 §1) — experimental

The timestamp check that nova verify has always run only proves the token's message imprint matches the DSSE envelope and that the embedded certificate's key produced the signature — a self-signed "TSA" certificate passes it. With an operator TSA CA bundle, the token is verified end to end, offline:

nova verify --tsa-ca-bundle /etc/novaseal/tsa/freetsa-cacert.pem path/to/capsule/
# or tsa_ca_certs: [...] in novaseal.yaml; --crl-dir/--crl-strict also apply

With TSA anchors given or configured, a capsule without a token (or with an empty manifest.dsse.tsr) fails verification: the .tsr is not covered by the DSSE signature, so otherwise a key holder backdating a capsule could simply delete it. Without TSA anchors a missing token stays a soft NOT PRESENT notice.

from novafabric.trust.novaseal.tsa_trust import verify_tsa_trust_chain

result = verify_tsa_trust_chain(
    tsr_bytes,                         # manifest.dsse.tsr (TimeStampResp or bare token)
    Path("/etc/novaseal/tsa/ca.pem").read_bytes(),   # or a list of Certificates
    crl_cache_dir=Path("/var/lib/novaseal/crl"),     # optional, never fetched
    expected_digest=hashlib.sha256(dsse_bytes).digest(),
)
# result.valid, result.reason, result.gen_time, result.chain_subjects, result.revocation

Checks, in order, stopping at the first failure (fail closed):

  1. The token parses strictly (positional DER walk, no BER, bounded sizes: 1 MiB token, 32 certificates) and PKIStatus is granted.
  2. The certificate named by SignerInfo.sid is found (embedded in the token, or passed as untrusted_certs), and the signed signingCertificate (ESSCertID, SHA-1) or signingCertificateV2 (ESSCertIDv2, RFC 5816) hash matches it.
  3. Signed contentType is id-ct-TSTInfo; messageDigest equals the hash of the TSTInfo (SHA-256/384/512 — SHA-1 is rejected).
  4. The CMS signature over the DER signed attributes verifies (RSA PKCS#1 v1.5, ECDSA, Ed25519; RSASSA-PSS is rejected, not verified).
  5. extendedKeyUsage is present, critical, and contains only id-kp-timeStamping (RFC 3161 §2.3); a keyUsage, if present, allows digitalSignature or nonRepudiation.
  6. The message imprint equals the expected digest (when given), and its hash algorithm is the expected one (SHA-256 by default) — matching octets under another or an unknown algorithm OID fail.
  7. RFC 5280 path validation to the bundle at genTime (depth ≤ 10), then the offline CRL check (§2.3): revocation status as of genTime, from CRLs that are current now. A keyCompromise / cACompromise entry, or one whose invalidityDate ≤ genTime, revokes even when dated after genTime. CA certificates get the WebPKI CA profile except that a non-critical basicConstraints is tolerated (cA=TRUE still required) — freetsa.org's root marks it non-critical.

The real freetsa.org token in tests/fixtures/rfc3161/ verifies against its own root this way. Honest limits: ESSCertID v1 is accepted unless require_ess_cert_id_v2=True (freetsa.org still emits v1, so ADR-0070 §4's v2 requirement is opt-in); a revocation dated after genTime for any other reason (e.g. superseded, cessationOfOperation) without an earlier invalidityDate does not invalidate earlier tokens — that relies on the CA recording keyCompromise when a key actually leaked; a CRL that has dropped an expired TSA certificate's entry cannot revoke it; the nonce is reported, not re-checked; no CRL fetching or nextUpdate-TTL cache (the operator's sync job owns freshness).


3. Rotation Procedure

Local nova seal init identity (experimental, ADR-0301): run nova seal init --force. It archives the old key, certificate and novaseal.yaml to keys/novaseal/archive/<UTC>/ (never deleted), generates a new P-256 key, issues its certificate from the same local CA, and appends a key_rotation entry (old_keyid, new_keyid) to the Merkle log. Verifiers who pinned keys/novaseal/ca.crt.pem keep validating old and new capsules. --new-ca also replaces the CA and breaks that continuity. The leaf is valid 5 years and --ca-bundle validates at verification time, so rotate before it expires. After a suspected compromise, destroy the archived private keys by hand. The procedure below applies to operator-managed keys.

Rotate keys on any of these triggers:

Rotation steps:

  1. Generate a new key pair using the procedure in §2.
  2. Update the NovaSeal signing profile to reference the new key (profile must be one of local/aws_kms/azure_kv/gcp_kms — see Configuration Reference §2):
    # ~/.novafabric/novaseal.yaml
    profile: local
    key_path: /path/to/new-signing-key.pem
    cert_path: /path/to/new-cert.pem
    # tsa_url: https://tsa.example.org/tsr   # optional, opt-in (ADR-0292) — a network call
    merkle_db: <keep your existing value>    # do NOT change on rotation: a new path starts a new, separate log
  3. Distribute the new public key to all verifiers.
  4. Backdate the old public key's effective expiry in your key registry — capsules signed with the old key remain verifiable using the old public key.
  5. Do not delete the old private key until all capsules signed with it have been verified and archived. Archive it encrypted in cold storage.
  6. Emit a key rotation event in a transparency log — future design. There is no Rekor integration for key-rotation events. A related but distinct primitive is shipped: trust/novaseal/witness.py (ADR-0097, experimental) implements C2SP-style checkpoint + witness cosigning (anti-split-view) over the existing Merkle log — it is a library API only, not wired to any nova CLI command, and does not itself publish anywhere; it is not a substitute for the Rekor-based rotation event described here.

4. Compromise Recovery

If a private key is suspected or confirmed compromised:

  1. Immediately revoke the key in your organisation's key registry / KMS.
  2. Notify all capsule consumers that capsules signed with the compromised key may not be trustworthy.
  3. Identify the time window of potential compromise.
  4. For capsules in the affected window: re-seal using a new key if the source data is still available.
  5. Preserve the compromised key's public key for forensic verification of pre-compromise capsules.
  6. Generate a new key (§2) and update all signing profiles (§3).
  7. File a security incident report per your organisation's IR procedure.
  8. If the key was stored in a cloud KMS, follow the cloud provider's compromise response playbook (GCP: "Key version destruction"; AWS: "Immediate disable + schedule deletion").

5. Multi-Region Availability

For production deployments requiring high availability of the signing service:

Option A: HSM cluster (on-premises) — future design

Not available: NovaSeal has no HSM/PKCS#11 backend (§2.1). Use Option B or C.

Option B: Cloud KMS with global replication

GCP:

gcloud kms keys create novaseal-production \
  --location global \
  --keyring novafabric-global \
  --purpose asymmetric-signing \
  --default-algorithm ec-sign-p256-sha256

GCP global keyrings replicate across all regions automatically.

AWS: Use AWS KMS with multi-region keys:

aws kms create-key \
  --key-spec ECC_NIST_P256 \
  --key-usage SIGN_VERIFY \
  --multi-region

(AwsKmsSigningBackend always signs with SigningAlgorithm=ECDSA_SHA_256, so the key must be ECC_NIST_P256 — there is no Ed25519 option on this path, not because of an AWS KMS limitation but because NovaSeal's AWS backend only ever requests ECDSA_SHA_256.)

Option C: Software keys with encrypted replication

For air-gapped or cost-sensitive deployments, replicate encrypted private key files across availability zones using an encrypted object store (e.g. S3 server-side encryption with KMS-managed keys). Ensure ACLs limit access to the NovaSeal signing service identity only.


6. PII Pepper Management (NOVA_PII_PEPPER)

The NOVA_PII_PEPPER environment variable is used by the PII Detection Gate (cap-001) for HMAC-SHA256 subject anonymisation. It is NOT a signing key but must be treated with equivalent care.

Rules:


7. Verification Key Distribution

Distribute the public verification key through a trusted channel:

Verifiers must pin the specific public key they trust — do not use a global "trust any NovaSeal key" policy.


8. References

Backups and key material (ADR-0216 D4)

nova backup create excludes signing keys by default — data backups may spread widely; key backups must not. A full disaster-recovery set can opt in with nova backup create --include-keys (packs the signing keyring and novaseal.yaml + its key/cert PEMs under external/…, flagged key_material), and restoring them requires the second opt-in nova restore --restore-keys. A set created with --include-keys must be handled under the custody rules in this document — encrypted cold storage, restricted access — never alongside ordinary data backups. The default posture is unchanged: keys are backed up separately per this procedure, and a restore without keys can verify everything but sign nothing.