BetterIAM
Secrets and keys

Private CA

Per-tenant X.509 authorities on KMS keys for mutual TLS, internal HTTPS and SPIFFE workload identity, with every certified name decided by policy.

new@better-iam/serverbetter-iamprivate-ca.mdpki.tsx509.tspki.ts

Services inside a company prove who they are with TLS certificates: an internal HTTPS endpoint needs one, and with mutual TLS (mTLS) the client presents one too. Public certificate authorities do not certify internal names such as api.payments.internal, so teams run a CA of their own, often a root key in a file and certificates minted by hand. Then nobody can say who may obtain a certificate for which name, a copied CA key certifies anything, and a certificate keeps working after the service account it was made for is gone.

Better IAM runs a private certificate authority (CA) for each , in the style of AWS Private CA, step-ca or a SPIFFE server. It issues X.509 certificates for mutual TLS between services, for internal HTTPS, and as workload identities: SPIFFE X.509-SVIDs that name a or an agent. Certificates are authorized by the same as everything else. Each name a certificate carries is decided on its own, so a team can be allowed to certify *.payments.internal and nothing else. The CA signing keys are KMS keys: they never leave the server, and disabling the key stops the CA.

Quick start

Create a root and an issuing authority

Once per tenant: a root, kept for signing intermediates, and an issuing (intermediate) authority for leaf certificates.

const root = await iam.api.pki.createAuthority(admin, {
  tenantId,
  name: 'Acme Root',
  subject: { commonName: 'Acme Root CA', organization: 'Acme', country: 'US' },
  pathLength: 1,
});
const issuing = await iam.api.pki.createAuthority(admin, {
  tenantId,
  name: 'Acme Workloads',
  parentId: root.id,
  subject: { commonName: 'Acme Workload CA' },
  trustDomain: 'acme.internal',
  permitted: { dnsNames: ['internal'], uriHosts: ['acme.internal'] },
});

Certify a service

The service makes its own key pair and a certification request. The private key never reaches IAM.

import { createCertificateRequest } from 'better-iam';
import { generateKeyPairSync } from 'node:crypto';

const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const csr = createCertificateRequest({ privateKey, dnsNames: ['api.payments.internal'] });
const { certificatePem, chainPem, rootPem } = await iam.api.pki.issueCertificate(credential, {
  tenantId,
  authorityId: issuing.id,
  csr,
  usage: 'server',
});

Serve it and trust the root

A TLS server presents certificatePem + chainPem, and its peers trust rootPem, or the tenant's bundle from iam.pki.bundle(tenantId). A server that terminates mutual TLS checks client certificates with iam.pki.verify.

Over HTTP the same calls are POST {basePath}/pki/{method}.

Authorities

createAuthority makes a root (self-signed) or an intermediate (signed by a parentId authority of the same tenant). Issue leaf certificates from intermediates and keep the root for signing intermediates only.

Prop

Type

Creating an authority needs iam:pki:create on iam/pki and . Conditions on it see resource.authorityType, resource.keySpec (for a new key) and resource.trustDomain (when one is given). An intermediate also needs iam:pki:update on its parent, and must fit within the path length of every authority above it. A tenant keeps at most 50 authorities.

Signing keys belong to their authority

A new authority gets a KMS signing key tagged pki-authority: {name}; an adopted keyId is taken over. Either way the key is marked managedBy: 'pki', and the KMS API refuses to sign with it, grant it or use it any other way directly (KEY_MANAGED), so nobody can sign a certificate body the CA did not decide. A key another authority or a data protection profile manages cannot be adopted.

  • The authority pins the key's current version, so rotating the KMS key never changes a CA's public key.
  • Disabling the KMS key stops the authority from signing anything, CRLs included, until the key is enabled again (KEY_STATE_INVALID).
  • Every signature is audited on the key as iam:kms:sign with via: 'pki'.

Disabling an authority

updateAuthority (iam:pki:update) disables or re-enables an authority (state) and changes maxValiditySeconds, defaultValiditySeconds or crlUrl (null clears it) for future certificates. A disabled authority issues nothing, and certificates it issued fail iam.pki.verify until it is active again. A revoked authority cannot be changed (AUTHORITY_UNAVAILABLE).

Issuing certificates

issueCertificate takes a PEM PKCS#10 certification request (csr). The request's signature must verify with its own key, which proves that the requester holds the private key. createCertificateRequest (exported from better-iam and @better-iam/server) builds requests without OpenSSL:

const csr = createCertificateRequest({
  privateKey, // KeyObject or PKCS#8 PEM: ECDSA P-256/P-384, Ed25519 or RSA (2048 bits or more)
  subject: { commonName: 'api.payments.internal' },
  dnsNames: ['api.payments.internal'],
  ipAddresses: ['10.0.4.12'],
});
  • Keys. The requested key is ECDSA P-256 or P-384, Ed25519, or RSA of 2048 to 8192 bits, and the request's signature algorithm must match it (INVALID_CSR otherwise).
  • Names. The certificate gets the request's subject common name and subject alternative names, unless the call passes commonName, dnsNames, uris, ipAddresses or emails itself. At most 100 alternative names. When there are alternative names, the common name must be one of them (clients may fall back to it).
  • Canonical names. What is decided is exactly what is signed. DNS names are lowercase ASCII, email addresses are lowercase ASCII, and URIs must already be in canonical form (no user information, dot segments, uppercase hosts or default ports; SPIFFE IDs follow the SPIFFE grammar). IP addresses are written in canonical text, an IPv4-mapped IPv6 address as its IPv4 address.
  • Usage. usage is both (the default), server or client: the extended key usage of the certificate.
  • Validity. validitySeconds defaults to the authority's defaultValiditySeconds, may not exceed its maxValiditySeconds (at least 60 seconds), and the certificate never outlives the authority. Certificates start 60 seconds in the past to allow for clock skew. A certificate that would expire within a minute is refused.
  • Result. certificatePem, chainPem (the intermediates, issuer first), rootPem, serialNumber, notBefore, notAfter and a SHA-256 fingerprint. A TLS server presents certificatePem + chainPem, and its peers trust rootPem (or the tenant's bundle).

Who may certify which names

Policies cannot say "every name in this list must match" directly, so the CA decides each name separately. iam:pki:issue is evaluated on iam/pki/{authorityId} once for the common name and once for every subject alternative name, and the certificate is issued only if every decision allows it. Each decision sees:

Context keyValue
resource.nameThe name being decided.
resource.nameTypecommonName, dns, wildcard, uri, ip or email.
resource.usageserver, client or both.
resource.validitySecondsThe requested validity.
resource.keyTypeecc-p256, ecc-p384, ed25519 or rsa-{bits}.
resource.authorityId, resource.authorityName, resource.authorityStateThe issuing authority.
resource.authorityType, resource.trustDomainroot or intermediate, and its SPIFFE trust domain.
Certify payments names for a day at most
{
  "effect": "allow",
  "actions": ["iam:pki:issue"],
  "resources": ["iam/pki/*"],
  "conditions": {
    "StringLike": { "resource.name": ["*.payments.internal", "payments.internal"] },
    "NumericLessThanEquals": { "resource.validitySeconds": 86400 }
  }
}

With that policy, a certificate for api.payments.internal is issued, while one that also names admin.internal is refused as a whole.

A wildcard DNS name such as *.payments.internal is decided with resource.nameType wildcard, never dns. It covers hosts a policy may deny one by one, so a policy that allows DNS names does not allow wildcard certificates: allow the wildcard name type explicitly where you want them.

Every issued name must also fit the name constraints (permitted) of the issuing authority and every authority above it, and so must a common name that looks like a host name. A name outside them fails with NAME_NOT_PERMITTED, whoever asks, owners and root administrators included.

A spiffe:// URI must be the certificate's only URI, and its trust domain must be the authority's trustDomain. Paths under /user/, /service/ and /agent/ name identities, so only requestCertificate issues them: asking for one with issueCertificate fails with NAME_NOT_PERMITTED.

Workload identity (SPIFFE)

requestCertificate gives the caller a certificate for its own identity: an X.509-SVID whose only name is the critical subject alternative name spiffe://{trustDomain}/{user|service|agent}/{identityId}, with an empty subject. The authority needs a trustDomain.

// A service account, with its own API key:
const svid = await iam.api.pki.requestCertificate(
  { token: serviceKey },
  { tenantId, authorityId: issuing.id, csr },
);
svid.spiffeId; // spiffe://acme.internal/service/{identityId}
  • Permission. iam:pki:request on the authority. The decision's resource.name is the SPIFFE ID, resource.nameType is uri, and resource.identityKind is the caller's kind, so policies can narrow workload certificates by kind or trust domain.
  • Validity. One hour by default (validitySeconds, at most a day and the authority's maximum), and never past the end of the session or API key that asked for it, or of the identity itself.
  • Callers. Only user sessions and API keys acting in their own right. Assumed roles, session tokens, delegated agent sessions and are refused.

Workloads renew their SVID before it expires by requesting a new one with a new key pair.

Verifying certificates (mutual TLS)

A server that terminates mutual TLS verifies the client's certificate chain, and learns who is calling, with iam.pki.verify:

import { X509Certificate } from 'node:crypto';

// In a Node TLS server with `requestCert: true`:
const presented = new X509Certificate(socket.getPeerCertificate().raw).toString(); // PEM
const peer = await iam.pki.verify(presented, { tenantId, usage: 'client' }); // tenantId is required
peer.identityId; // for workload certificates
peer.spiffeId;

verify accepts only certificates this deployment issued. It looks the certificate up by serial number and requires the presented certificate to be byte-for-byte the stored one. It then walks the stored chain, never the chain the client sent, checking every signature. It also checks:

  • the validity period and the requested usage;
  • revocation, and that the certificate was issued in tenantId (trust is per organization);
  • that every authority in the chain is active, unexpired and within its path length;
  • for workload certificates, that the identity is still active, unexpired and (for agents) not suspended.

Anything else fails with CERTIFICATE_INVALID (401). CA certificates never authenticate. The result carries the serialNumber, fingerprint, subject, dnsNames, uris, notAfter and issuing authorityId, plus spiffeId and identityId for workload certificates.

Revocation and CRLs

revokeCertificate (iam:pki:revoke on the issuing authority) takes a serial number and a reason: unspecified (the default), keyCompromise, caCompromise, affiliationChanged, superseded, cessationOfOperation or privilegeWithdrawn. A revoked certificate fails iam.pki.verify at once and appears on the authority's next CRL.

Revoking an authority's certificate revokes the authority

A subordinate authority's own certificate is listed under its parent, with usage: 'ca'. Revoking it revokes the authority itself and every authority below it (authoritiesRevoked in the audit event). Nothing they issued verifies any more, and they can never issue again.

Each authority publishes a signed CRL (RFC 5280) listing its revoked certificates that have not expired yet. The CRL is rebuilt on the first request after a revocation, or once the current one is 12 hours old, and each one is valid for 24 hours. CRLs are public by design. Serve them at the crlUrl you gave the authority:

// GET https://pki.acme.internal/crl/{authorityId}.crl
app.get('/crl/:id.crl', async (request) => iam.pki.crlResponse(request.params.id));

crlResponse answers application/pkix-crl, or 404 for an unknown authority. When a fresh CRL cannot be signed (the authority's KMS key is disabled), it serves the last one while that is still valid, and answers 503 after that.

  • api.pki.crl returns the same CRL as PEM to callers with iam:pki:read, and iam.pki.crl(authorityId) to server code without a credential.
  • iam.pki.bundle(tenantId) (or api.pki.bundle) returns the tenant's active, unexpired root certificates as one PEM bundle, for relying parties to trust. Intermediates are not trust anchors: servers send them with their own certificate (chainPem).

Permissions

ActionResourceCalls
iam:pki:createiam/pkicreateAuthority (plus iam:pki:update on the parent for intermediates)
iam:pki:readiam/pki/{id}getAuthority, listAuthorities and listCertificates (per authority), getCertificate, crl, bundle
iam:pki:updateiam/pki/{id}updateAuthority
iam:pki:issueiam/pki/{id}issueCertificate, once per name
iam:pki:requestiam/pki/{id}requestCertificate
iam:pki:revokeiam/pki/{id}revokeCertificate

Decisions on an authority see resource.authorityId, resource.authorityName, resource.authorityType, resource.authorityState and resource.trustDomain. Callers from another tenant are refused before anything is looked up, and sessions that view as someone else cannot use the CA.

Audit

Every call is audited with its action on pki/{authorityId} (pki for tenant-wide calls). Issuing records the serial number, every name as {nameType}:{name}, the usage and the expiry. Workload certificates also record the SPIFFE ID, and revocations record the serial number and reason. Refusals after the call was authorized (a name outside the constraints, an invalid request, an unavailable authority or key) are audited as deny with a reason too. Private keys never reach IAM, so they never appear anywhere.

Errors

CodeStatusWhen
INVALID_CSR400The request is not valid DER, is not signed by its own key, or uses an unsupported key or algorithm.
NAME_NOT_PERMITTED403A name is outside the authorities' name constraints, or a SPIFFE ID is outside the trust domain.
AUTHORITY_UNAVAILABLE409The authority (or one above it) is disabled, revoked or expired.
CERTIFICATE_INVALID401iam.pki.verify refused a certificate.
KEY_STATE_INVALID409The authority's KMS key is disabled or pending deletion.
KEY_MANAGED409Adopting a KMS key another authority or a data protection profile manages.
CONFLICT409The authority name is taken, the trust domain belongs to another tenant, or the certificate is already revoked.
ACCESS_DENIED403A name, the authority or the workload request is not allowed.

Creating authorities can also fail with LIMIT_EXCEEDED (past 50) and RECENT_AUTH_REQUIRED, and malformed input with INVALID_INPUT.

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page