EN · ORIGINAL REPOSITORY DOCUMENT
Key rotation and recovery
Original English text. Commands and evidence apply to the revision and environment stated in the document.
product/licensing/go/ROTATION-RECOVERY.md
On this page
Signing rotation and conservative restored-state reconciliation
These APIs support an operator-controlled rotation and a conservative recovery step. They do not verify billing, discover authoritative state, or provision a public service. All examples refer to private local material prepared by the operator. Use the operations guide for directory permissions, consistent snapshots and isolated recovery paths.
RSA signing identity
Store.SetSigningKeyID(id string) error selects the identifier emitted in signed
entitlement envelopes. The default remains fixture-v1 for compatibility. IDs
must contain 1..64 ASCII letters, digits, dots, underscores or hyphens and begin
with a letter or digit. Invalid input leaves the current identifier unchanged.
The setter is safe against concurrent signer reads, but configure it before
serving so each RSA key has an intentional stable identifier. It changes neither
private key bytes nor the signed JSON claim contract. Private PEM loading remains
an explicit startup operation; there is no private-key hot reload.
For a new RSA signer, first distribute an overlapping trusted PHP public-key map:
'public_keys' => [
'fixture-v1' => $trustedPreviousPublicPem,
'original-v2' => $trustedNextPublicPem,
],
Then prepare a new server instance with the new private PEM and call
SetSigningKeyID("original-v2") before accepting requests. CLI integration supplies
serve --signing-key-id original-v2; omitted configuration preserves the existing
default. Keep state, ownership and deadlines unchanged during a signer-only
rotation. Never use a key identifier that points clients to a different public
key than the server’s loaded RSA key, or fetch public-key trust from an unsigned
service response.
Clients with both keys can reload old cached envelopes and verify newly issued envelopes. Retain the old public key until its issued entitlements have reached their original signed grace deadlines, accounting for the operator’s rollout and clock policy. Removing the old key makes its cached envelopes unverifiable; that is an intentional loss of access, not an automatic migration. An unknown key ID cannot establish new authority. A failed refresh retains a previously verified cache’s existing deadlines without extending them. No workflow can erase a client’s offline verified cache remotely.
RSA rotation does not change HMAC download-token validity. Existing HMAC tokens still require live active ownership, approval, nonrevocation, ZIP digest and their five-minute/license deadline. Changing the server-only HMAC material at restart invalidates previously issued download tokens, requiring fresh update metadata. It does not invalidate RSA-signed entitlements. These are separate operations; neither is a substitute for current authorization state.
Explicit authority and recovery API
A restored backend must stay disconnected from production clients/proxies while recovery is reviewed. An old backup with retained keys can forget a subsequent revocation and accept an unexpired old token. Reconciliation removes rights from the restored receiver database, using an independently trusted, explicitly acknowledged authoritative latest Go JSON snapshot:
report, err := restoredStore.ReconcileRestored(ReconcileOptions{
AuthoritativePath: "/private/authority/latest-licenses.json",
AuthoritativeSHA256: "<exact-64-character-lowercase-SHA256>",
ConfirmAuthoritativeLatest: true,
})
The operator must obtain and verify that authority through their existing trusted custody/recovery process. The checksum records the exact input and catches a changed snapshot; it does not authenticate its provider, prove freshness, or infer which unsigned JSON is latest. There is no state revision, signed snapshot provenance or automatic audit-journal reconciliation. An acknowledgment is an operator assertion. If current authoritative evidence is missing, leave the recovered service off.
Both paths must already contain valid, bounded Go state, with explicit schema 1
or 2 and a license map. Missing files, corrupt state, symlink aliases, shared file
identity, shared lock identity, digest mismatches and a missing latest-authority
acknowledgment are rejected. Use distinct canonical regular paths in private
local directories. The API takes both fixed PATH.lock files in lexical path
order and holds them through target publication, preventing opposite-direction
operations from deadlocking or interleaving a cooperating authority writer with
the commit. It never locks the atomically replaced JSON inode. The original
atomic temporary-file write, file fsync, rename and directory fsync apply to the
receiver only. The authoritative JSON is not changed; its fixed lock file may be
created if none exists.
Conservative changes
The API does not import licenses, add approved origins, increase expiry/site limits, enable activations, replace installation ownership or rewrite customer/ order links. Its effects are intentionally stricter than simply copying latest state:
- A restored license absent from authority is revoked. Existing revocation is sticky; authority cannot un-revoke it.
- Expiry and site limit become the smaller restored/authoritative values. Renewals or higher limits are not imported by this recovery step.
- Approved origins are intersected. Removing any restored approval also revokes that license permanently. Ownership rows for removed approvals are pruned because the current schema requires each row to reference an approved origin. Pruning is counted explicitly; revocation prevents that empty origin slot from being claimed by a replacement installation later.
- Retained ownership rows keep the restored installation digest. A missing or different authoritative owner revokes the license instead of replacing it. Conversely, an authoritative binding on a retained approved origin where the restored backup has no row also revokes the license: a pre-first-activation backup must not expose a new installation slot.
- Active status survives only when it was already active and authority still has the same active owner. Revoked or expired licenses have all active flags cleared. A newer authoritative activation is never turned on in the restored database.
CustomerIDandOrderIDare preserved exactly. Any discrepancy, including a missing link on either side, revokes the restored license. No customer/order reassignment or cross-customer license reuse occurs.
There is no un-revoke/reissue path in this API. Conservative conflicts can remove access that an operator later determines was legitimate. Remediation requires a new, separately authorized license/order and independently approved installation ownership through the appropriate operator workflow; do not manually unset the revocation flag or silently discard a retained owner.
Repeated reconciliation never re-enables a disabled activation or restores a removed approval. The receiver schema is preserved; customer-bound schema-2 records are not downgraded. A transaction error before publication returns no success receipt and leaves the receiver unchanged. As with all current state writes, a storage failure after rename may have committed despite the error; keep the service isolated and investigate before retrying/cutover.
Receipt and checks
ReconcileReport returns the acknowledged authoritative_sha256 plus aggregate
counts only: licenses_reviewed, licenses_revoked, unknown_licenses,
customer_order_mismatches, ownership_conflicts, expiries_reduced,
site_limits_reduced, origins_removed, activations_disabled and
bindings_removed. It includes no bearer, license/customer/order identifier,
site or installation digest. Counts distinguish newly revoked licenses and newly
disabled/pruned rows from already restricted records; conflict/unknown counts can
remain nonzero on repeated runs because the identities remain unresolved.
Retain the receipt with the operator’s authority acknowledgment and custody record.
Run the focused tests from this directory:
go test -race -run 'TestSigningKeyID|TestReconcile' -v ./...
LICENSING_PHP_DOCKER_IMAGE=<existing-php-image> \
go test -race -run '^TestSigningKeyIDPHPOverlap$' -v ./...
The optional PHP check uses one automatically removed, network-disabled CLI container and temporary synthetic RSA keys/envelopes. It exercises the actual PHP client’s overlapping key map, old-cache reload, new signer ID, unknown-key denial, retired-key denial and unchanged cached deadlines. Go checks cover distinct new RSA signatures, default ID compatibility, setter validation/concurrency, HMAC separation, conservative customer/ownership conflicts including pre-first-binding backups, missing/corrupt/aliased inputs, authority integrity, idempotence and 16 opposite-path concurrent reconciliations. All fixtures use private temporary state and synthetic identities/packages. No production material, customer account, billing callback or deployment is involved.