Skip to content

How does sluis sign tokens on AWS?

The aws-serverless, aws-hybrid and k8s-aws presets sign with the kms-wrapped adapter, called the ring. The key policy and IAM statements are in the signing key policy. The library arguments are in the Pulumi library. Direct signing with signingKey.kms is deprecated and warns at start.

kms (direct, deprecated) the ring (kms-wrapped)
Keys one asymmetric KMS key per algorithm one symmetric key the estate supplies, named by alias (keys.sign)
Signing one kms:Sign per token; the private key never leaves KMS local, with a key decrypted into memory (kms:Decrypt once per key per process)
Verifying the published public keys the published public keys, with no KMS call
If the signing role leaks the holder signs only while it holds the role, and CloudTrail records every signature the holder decrypts the wrapped keys in the State and signs offline with no trace while they are published
Throughput the account's Sign quota none from KMS
Rotation append a key by hand automatic, every rotateEvery (default 24h)

How does the ring work?

For each algorithm (ES384, RS256) the process generates a key pair locally. It wraps the private half with kms:Encrypt under the sign key and the context {instance, purpose: sign}. It records the public key, the wrapped key and that context in the State as issuer:keyring:entry:<alg>:<kid>. The kid is a random 128-bit id. instance is the document's instance, falling back to its release.

To sign, a process calls kms:Decrypt, keeps the key in memory and signs locally. EdDSA is refused, because the issuer knows only RSA and ECDSA.

Every rotateEvery a replica generates a new pair per algorithm under the State lease lease.signing-keygen:<alg>. A lost race costs one more published key, never a wrong token.

A new key enters the JWKS at once and signs after prepublish (default 15m). Envoy's jwt_authn caches a key set for 10 minutes and does not refetch on an unknown kid. A replaced key stays published for retain, which defaults to lifetimes.token plus 5m of skew. At start retain must be at least that, and rotateEvery must exceed prepublish and be at most 168h.

A process looks for work at most every pollInterval (30s), on a request, and on Kubernetes on a timer.

The sign key, and moving between signers

The estate owns the key that wraps the ring. Name it by alias in keys.sign and in the Pulumi library's Keys.Sign:

keys:
  adapter: kms
  sign: alias/sluis-<instance>-sign      # or {key: alias/..., context: off | {..}}

The library grants Encrypt, Decrypt and GenerateDataKey on the key behind the alias, only under the context {instance, purpose: sign}. It creates no key and no kms:Sign grant. Other workloads may share the key, because the context keeps ciphertexts apart.

Letting the library create the key (WrappedSigning) is deprecated. See its policy and the move to supplied aliases.

An entry from an earlier release records no context and opens with {purpose: sluis-signing, alg, kid}. The ring turns over within rotateEvery plus retain, so no entry needs migrating. The library keeps the older grant while Keys.LegacySigningContext is true (the default). Set it false once the ring has rotated. Setting it early stops signing if the active key is an old one.

To move from direct KMS signing, list each direct key's public key under signingKey.verifyOnly with its kid and an until that covers its tokens plus the verifiers' cache. Replace signingKey.kms with keys.sign. The ring signs from the first start and old tokens keep verifying. signingKey.kmsWrapped.keyId is the deprecated spelling of keys.sign.

Trust boundary

Whoever may call kms:Decrypt with the signing context can extract a wrapped key, and the ciphertext is in the State. A direct key is not extractable. Choose kms where that matters more than rotation and quota.

Write access to the State is inside the trust boundary. Whoever can write the key ring items (keyring, keyring-index, keyring-retired) or the lease can plant a public key, retire a key early or stop rotation.

A key a leaked role has decrypted stays valid while published: its life as the active key plus retain. No role is denied the key ring, because the controllers run in the signing process.

The key policy and the account's IAM decide who may call kms:Decrypt. Keep that to the signing roles. Otherwise a CI role or a PowerUser can read a wrapped key from the State and forge. Only a CloudTrail Decrypt records it. On a shared key, deny purpose=sign to every principal but the signing roles.

Principals with kms:PutKeyPolicy can widen who may decrypt, so they are inside the boundary. Its key policy leaves the control plane to the named key administrators (key policy). A multi-Region key needs the same policy in every replica.

Known limits

A process publishes a key it reads from the State without unwrapping it. Foreign entries are published with no allowlist. The encryption context has no creation time, so an over-age wrapped key is not refused.

A process that cannot unwrap the newest key signs with an older one and logs it. The first key of a cutover is active at once, so a verifier with a cached JWKS rejects the new kid for its cache lifetime.

Decided in

  • ADR 0009: default algorithm and exceptions
  • ADR 0041: the secret contract, keys.sign and its context