Signing and key epochs
Every manifest is signed. Signatures are mandatory; there is no unsigned mode in the loader (the build-time SDKs have an allowUnsigned switch for local development only).
What is signed#
signature is a base64url Ed25519 signature over the RFC 8785 (JCS) canonical form of the manifest as received with the top-level signature member removed. Members the verifier does not know are part of the signed data, so adding fields later does not break old verifiers. Only integers are allowed as numbers. kid names the signing key. The repository's manifest-signature-vectors.json and signature-cross-language-vectors.json are shared test vectors that the C#, TypeScript and PHP verifiers use.
Language files are not signed individually: the signed manifest lists each file's SHA-256, and the loader checks it.
To check a published bundle from Python, PHP or Go, see Verify a published bundle.
Configuring the keys#
<script async src="https://cdn.example/loader/v1/loader.js"
data-site-key="rl_site_live_abcdefghijklmnopqrstuv"
data-public-key="EXAMPLEPUBLICKEYEXAMPLEPUBLICKEYEXAMPLEPUBL"></script>
data-public-key takes one or more raw Ed25519 public keys (base64url), oldest first. An entry can be written kid=KEY, which only matches manifests whose kid equals that label; a bare key matches any kid. A manifest without a kid, with an unknown labelled kid, or signed by none of the keys is refused.
Rollback protection and key epochs#
The loader remembers the highest manifest it has accepted as a pair: the position of the signing key in the list (its rank) and the version. A new manifest is judged like this:
| Signed by | Accepted when |
|---|---|
| The same key as the remembered one | its version is at least the remembered version |
| A key later in the list | always, whatever its version; it starts a new epoch and replaces the remembered state |
| A key earlier in the list | never |
So a stolen or buggy key can be recovered from: add a standby key at the end of data-public-key, publish a manifest signed by it, and every visitor who sees that manifest moves to the new epoch even if its version number is lower. Manifests signed by the old key are then refused. Versions above 2^53-1 are rejected so a poisoned version cannot freeze a site.
Limits of this design. Recovery needs a standby key to be configured in the page or plugin before it is needed. With a single key, a manifest with an unreasonably high version signed by that key (for example from a bug) blocks that key's lower versions in browsers that saw it. Detecting a "freeze" by checking a second origin is not implemented. Both are recorded in the security findings (F-33).
Where keys live#
Signing keys are per site, so the theft of one key affects one site. For development and first launch the application holds the key encrypted at rest; moving to a KMS or vault before scale is the plan. Plugins and the build-time SDKs verify with the same rules. The public keys are public; the private key never leaves the platform.
Rotating without downtime#
- Generate a new key pair and publish its public key to your pages or plugin settings after the current key, for example
OLDKEY NEWKEY. Pages keep working because a manifest from the old key is still accepted. - When every page and plugin has the new list, switch publishing to sign with the new key. Browsers move to the new epoch on their next manifest fetch.
- Remove the old key from the list at your leisure.
The exact dashboard and API steps for this are not built yet (verify in a later release).