Artifact / Code Signing (CMS + RFC 3161)¶
secsy-pki includes a general-purpose release-artifact signing service: it
produces detached CMS/PKCS#7 (RFC 5652) signatures over files (or file
digests) with HSM-backed code-signing keys, using signer certificates the
PKI itself issues under the lint-gated code-signing profile
(EKU id-kp-codeSigning). A signature can optionally embed an
RFC 3161 timestamp countersignature from the built-in
Time-Stamp Authority, so it stays verifiable long after the
signing certificate expires.
Typical uses: signing release tarballs, container/image manifests, firmware,
installers, SBOMs — anything a downstream consumer should be able to verify
against your PKI's trust anchors with standard tooling (openssl cms,
openssl ts).
How it works¶
caller ── artifact (or its digest) ──▶ POST /api/sign / secsy-ca sign
│
resolve signer (key label + certificate)
│
build CMS authenticated attributes
(contentType, messageDigest, signingTime,
ESS signing-certificate-v2)
│
sign the attribute set on the HSM
│ (optional)
├─▶ hash(signature) ─▶ in-process TSA
│ ◀─ TimeStampToken (HSM-signed) ──
│ embed as id-aa-timeStampToken
│ unsigned attribute (RFC 3161 A.)
▼
caller ◀── detached SignedData (DER / PEM "PKCS7") ──
Key properties:
- Detached, always. The artifact is never embedded; the signature is a
small sidecar file (
.p7s) and the artifact's distribution channel is unchanged. - Keys never leave the provider. One
crypto.Signeroperation per signature on the code-signing key, plus one on the TSA key when countersigning. Works with PKCS#11 HSMs (SoftHSM in tests), cloud KMS, or the software keystore; the backend is selectable per role viakey_provider.roles.signing. - Digest input for large artifacts. Callers may submit just the artifact's
hash (in the signer's digest algorithm). The CMS
messageDigestattribute is the hash, so the resulting signature is byte-for-byte the same as one made from the full content — and verifies against the full artifact. Use this for multi-GB images (the API caps request bodies at 8 MiB). - ECDSA and RSA signers. The CMS layer emits
ecdsa-with-SHA*or RSA PKCS#1 v1.5 signatures; both verify withopenssl cms -verify. - Timestamps extend verifiability. A countersigned artifact's chain is validated at the token's genTime; an unstamped one at the wall clock. After the signer certificate expires, only the countersigned signature keeps verifying — the reason release pipelines pair code signing with a TSA.
CAdES baseline levels (B / T / LT)¶
The signatures are CAdES (ETSI EN 319 122) baseline signatures. Choose the
level per request (-level / "level") or set a per-signer default; each level
builds on the previous one:
| Level | Adds | What it buys | Requires |
|---|---|---|---|
b — CAdES-B |
signing-certificate-v2 (ESSCertIDv2) + signing-time signed attributes |
binds the signer certificate into the signature; records the claimed signing time | — |
t — CAdES-T |
an RFC 3161 signature-timestamp (id-aa-timeStampToken) over the SignerInfo signature value, from the built-in TSA |
a trusted time anchor, so the signature survives signer-certificate expiry | tsa.enabled |
lt — CAdES-LT |
long-term-validation material: the signer/CA chain plus current CRLs and OCSP responses, embedded in the SignedData.crls field and the id-aa-ets-revocationValues unsigned attribute |
offline, self-contained validation of both the signature and its revocation state after the certificates expire | tsa.enabled + an internal CA that can produce OCSP/CRLs |
Every signature this service produces is at least CAdES-B (the B attributes are
always present). t is exactly the previous timestamp behavior — -timestamp
yes and -level t are equivalent. lt is the new archival level: the server
gathers a fresh OCSP response for the signer certificate and the complete CRL of
each CA in the chain (all HSM-signed by the CA key), embeds them, and the
verifier can then confirm the chain was not revoked without any network access.
The archival CAdES-LTA level (periodic archive-timestamps that re-protect the material as algorithms weaken) is out of scope; re-sign or add an external archive timestamp when you need multi-decade preservation.
In the operator console, the Signing page exposes the level as a per-signature dropdown (signer default / B / T / LT), shows each signer's default level in the signer table, and reports the achieved level (and any embedded CRL/OCSP counts) on the result; the verify panel adds a require-level gate so an operator can confirm a signature reaches at least CAdES-T or -LT.
Provisioning a signing key + certificate¶
# 1. A CA must exist (see certificate-authority.md), e.g.:
secsy-ca init-root -cn "Example Root" -label "Example Root"
# 2. Generate the signing key on the provider and issue its certificate
# through the ordinary issuance path (code-signing profile, pre-issuance
# lint gate included):
secsy-ca signing-key -ca "Example Root" -label codesign-release \
-cn "Release Signing" -o "Example Corp" -chain -out /etc/secsy/codesign.pem
signing-key flags: -key-type (ecdsa-p256 default; ecdsa-p384,
rsa-2048, rsa-4096), -validity-days (0 = profile default, 3 years),
-profile (default code-signing; a custom profile must still carry the
codeSigning EKU — the command refuses otherwise), -chain (append the issuer
chain to the PEM). Re-running with the same -label reuses the existing
key and reissues the certificate — certificate renewal without key rotation.
The certificate is recorded in the store like any other leaf, so it shows up in
inventory, expiry monitoring, and can be revoked.
For the countersignature, provision the TSA once as well
(secsy-ca tsa-key, see timestamping.md).
Configuration¶
signing:
enabled: true
signers:
- name: release # callers reference this name
key_label: codesign-release # provider label from signing-key
certificate_file: /etc/secsy/codesign.pem # written by signing-key
ca_label: "Example Root" # or ca_id; completes the chain
# when the file holds only the leaf
digest: sha256 # sha256 | sha384 | sha512
timestamp: true # RFC 3161 countersign by default
# (requires tsa.enabled)
level: lt # default CAdES level b|t|lt
# (overrides timestamp; lt needs
# tsa.enabled + an internal CA)
tenant: "" # owning tenant ("" = default)
Multiple signers may be configured (e.g. release, firmware, nightly),
each with its own key, certificate, tenant, and default CAdES level. The signing
keys may live on a dedicated backend via key_provider.roles.signing; the
long-term-validation revocation material for CAdES-LT is signed by the CA
key, not the signing key.
HTTP API¶
All endpoints require operator authentication; see the OpenAPI spec
(/api/docs) for full schemas.
| Endpoint | Capability | Notes |
|---|---|---|
POST /api/sign |
artifact:sign (signer role) within the signer's tenant |
body: {"signer":"release","artifact":"<base64>"} or {"signer":"release","digest":"<hex>"}, optional "level":"b"\|"t"\|"lt" (or the legacy "timestamp":true/false). Returns the DER signature (base64 + PEM), the signer certificate, digest, the achieved level, and timestamp / embedded-revocation details. |
POST /api/sign/verify |
any assigned role | body: {"signature":"<base64-or-PEM>","artifact":"<base64>"} (or "digest"), optional "ca_id", "require_timestamp", "require_level":"b"\|"t"\|"lt". Trust anchors are the caller's tenants' CAs. Returns HTTP 200 with valid:true/false, the achieved level, and revocation-material counts. |
GET /api/sign/signers |
any assigned role | configured signers, filtered to the caller's tenants. |
The dedicated signer RBAC role grants artifact:sign and log reading —
and nothing else. It is deliberately separate from issuer: a CI credential
that signs builds cannot mint certificates, and vice versa. Assign it
platform-wide or per tenant exactly like the other roles (rbac: /
tenants[].rbac: / OIDC claim mappings).
POST /api/sign is rate-limited and HSM-concurrency-guarded (the
/api/sign prefix class of the Task 25 middleware, keyed per credential), so a
runaway pipeline cannot starve ACME issuance or OCSP signing. Every operation
is audited: artifact.sign records the signer, artifact digest, and
whether a countersignature was embedded; artifact.verify records verification
verdicts.
CLI¶
# Sign a file at a CAdES level (-level b|t|lt; empty uses the signer default).
# -format der|pem; the legacy -timestamp auto|yes|no still works when no -level.
secsy-ca sign -signer release -in release.tar.gz -level lt
# Sign by digest (the artifact itself never leaves the build host)
secsy-ca sign -signer release -digest "$(sha256sum huge.iso | cut -d' ' -f1)" -out huge.iso.p7s -level t
# Verify (no HSM needed — works anywhere with the store or a CA PEM). It prints
# the achieved CAdES level; -require-level fails when the signature is below it.
secsy-ca verify-signature -sig release.tar.gz.p7s -in release.tar.gz -require-level lt
secsy-ca verify-signature -sig huge.iso.p7s -digest "<hex>" -ca-file root.pem
sign uses the local provider directly (the pipeline-host counterpart of
POST /api/sign); verify-signature needs only public keys and exits non-zero
on any failure.
Verifying with standard tools¶
# Signature over the artifact (chain to your root; -purpose any because
# openssl's default S/MIME purpose check rejects codeSigning certificates):
openssl cms -verify -binary -inform DER -in release.tar.gz.p7s \
-content release.tar.gz -CAfile root.pem -purpose any -out /dev/null
# The embedded RFC 3161 token covers the CMS signature *value*: extract the
# id-aa-timeStampToken attribute (secsy-ca verify-signature prints its details)
# and check it with openssl ts:
openssl ts -verify -digest <hex sha256 of the SignerInfo signature> \
-token_in -in token.der -CAfile root.pem -untrusted tsa.pem
Both interop paths run in CI against SoftHSM
(server/internal/signing/signing_softhsm_test.go).
Verification semantics¶
secsy-ca verify-signature / POST /api/sign/verify / signing.Verify are
fail-closed and check, in order:
- the CMS signature cryptographically covers the supplied content (or digest);
- the signer certificate has the code-signing shape (codeSigning EKU, digitalSignature KU, not a CA) and, when the ESS signing-certificate-v2 attribute is present, that it binds this exact certificate;
- an embedded timestamp token, if any, itself verifies: token signature, imprint over the signature value, TSA EKU, TSA chain to the same trust anchors at genTime, genTime not in the future;
- the signer chain builds to the trust anchors at the validation time — the token's genTime when countersigned, else now — for the codeSigning EKU;
- any embedded long-term-validation material (CRLs in
SignedData.crls/ OCSP inid-aa-ets-revocationValues) is parsed and, if it authentically shows the signer certificate revoked, verification fails closed.
The verifier then reports the achieved CAdES level (b/t/lt) and, with
-require-level / require_level, fails when the signature is below the
required floor. LT is reported only when a valid timestamp and embedded
revocation material that covers the signer are both present.
Verification does not fetch fresh revocation data over the network — CAdES-LT's
point is that everything needed is embedded. For a live revocation check outside
the signature, the signer certificate is in the issued-certificates inventory,
so secsy-ca revoke and the PKI's CRL/OCSP endpoints work on it like any leaf.
Key-ceremony notes for signing keys¶
Code-signing keys sit between CA keys and TLS leaves in sensitivity: a stolen signing key lets an attacker ship trusted malware until the certificate is revoked and consumers notice. Recommended handling (see key-ceremony.md for the general HSM procedures):
- Generate on-device, under ceremony. Run
secsy-ca signing-keyagainst the production token so the key is born non-extractable (CKA_EXTRACTABLE=false, enforced by the provider). For high-value release keys, run it inside the same M-of-N witnessed procedure as an intermediate CA (secsy-ca ceremonyrecords operator confirmations in the audit chain); the audit events (cert.issuewithrequested_by="secsy-ca signing-key") are your provisioning record. - One key per purpose/pipeline. Separate
releasefromnightlyfromfirmwaresigners; scope each to its tenant. Compromise or retirement of one does not invalidate the others, and per-signer metrics/audit make usage anomalies visible. - Separation of duties. Grant pipelines the
signerrole only. Key provisioning stays with CA operators (signing-keyneeds direct provider/store access); nobody needs the raw key, ever. - Backup = replicate, don't export. Signing keys follow the same rule as CA keys: never leave the device in software form. Use the HSM's native cloning/wrap-under-KEK mechanism to a backup token (see hsm-migration.md), or accept key loss as a re-provision event — unlike a CA key, a signing key is cheap to replace (issue a new certificate, old signatures stay valid thanks to their timestamps).
- Rotate by certificate, roll the key on schedule. Re-running
signing-keywith the same label renews the certificate on the same key; use a fresh label (and a config update) to rotate the key itself — e.g. yearly, or immediately on any suspicion of compromise. - Revocation plan. If a signing key is compromised:
secsy-ca revokethe certificate (CRL/OCSP pick it up), remove the signer fromsigning.signers, and re-sign still-supported artifacts with a new key. Timestamped signatures made before the compromise window remain provably older than the revocation — one more reasontimestamp: trueshould be the default for release signers. - Always countersign releases. Signing certificates are deliberately
shorter-lived than the artifacts they cover; the RFC 3161 token is what
keeps a 3-year-old release verifiable. Keep
tsa.enabledon and leavetimestamp: trueon release signers.
Metrics¶
| Metric | Labels | Meaning |
|---|---|---|
secsy_artifact_signatures_total |
signer, result |
signing operations (success/denied/error) — each success is one HSM signature |
secsy_artifact_timestamps_total |
result |
RFC 3161 countersignature sub-step outcomes |
secsy_artifact_verifications_total |
result |
verification verdicts (valid/invalid/error) |
Rate-limit visibility comes from the shared secsy_ratelimit_* /
secsy_hsm_guard_* families under the artifact_sign class.
Limitations¶
- The countersigning TSA is the in-process one; pointing at an external RFC 3161 service is not supported (run secsy-pki's TSA next to the signer instead).
- CAdES-LT gathers revocation material for the signer chain (signer leaf + its issuing CAs). The TSA certificate's own chain is embedded inside the timestamp token but its revocation is not separately gathered; add an archive-timestamp (CAdES-LTA, out of scope) if you need that too.
- CAdES-LT only embeds material for certificates issued by a CA known to this deployment; a signer chained to an external/offline root gets B or T only.
- Live network revocation fetching is intentionally not performed at verify time
(CAdES-LT embeds everything). Pair with
secsy-ca revoke/ the CRL/OCSP endpoints for a live check outside the signature. - The TSA key must be RSA (see timestamping.md); the code-signing keys themselves may be ECDSA or RSA.