Offline Kit — rotation runbook
Audience: Scanner operators, Security Guild, Offline Kit maintainers, air-gap site administrators.
Companion docs:
offline-kit-assembly.md(the assembler contract this runbook drives) andsecrets-bundle-rotation.md(sibling rotation discipline for secret-detection rule bundles).
This runbook covers the productionised Offline Kit baseline pipeline: when to rotate, who custodies the signing key, how to publish the new release, and how downstream consumers verify what they received.
The baseline pipeline itself (the deterministic assembler + manifest contract) is documented in offline-kit-assembly.md. This document sits on top of that contract and adds the operational discipline.
1. When to rotate
| Trigger | Cadence | Mechanism |
|---|---|---|
| Standard weekly rotation | Mondays 04:00 UTC | Gitea Actions schedule (.gitea/workflows/offline-kit-assembly.yml) |
| Out-of-band security advisory | Within 4 hours of advisory | workflow_dispatch with an explicit version input |
| Dataset hotfix (trust-store / package mirror / OS metadata) | Within 24 hours of the dataset PR merging to main | workflow_dispatch |
| Plug-in manifest change (new analyzer, signed manifest update) | At the next scheduled run | Scheduled cadence (no manual action needed) |
| Key rotation (production signing key) | Per key-custody policy below | See §3 |
The weekly cadence keeps the Offline Kit fresh against the dataset PRs that land in main between releases while bounding the artifact retention budget. Consumers should treat any kit older than 30 days as expired and re-pull from the latest scheduled run.
1.1 Skipping a scheduled run
A scheduled run is never skipped silently. If the assembler fails (e.g., determinism harness drift, dataset enumeration error), the workflow fails loudly and a ::error:: annotation is recorded. The operator on call must then:
- Inspect the failed run’s logs in the Gitea Actions UI.
- If the cause is a transient runner issue, re-trigger via
workflow_dispatch. - If the cause is a determinism regression, STOP — file a Sprint ticket against the assembler. Do NOT publish a drifted kit.
- If the cause is a dataset / manifest issue, fix the upstream change and let the next scheduled run pick it up.
2. How to publish a new release
The CI workflow uploads two/three artifacts under the name offline-kit-<version>-<run-id>:
offline-kit-baseline-<version>.tar.gz # The kit itself.
offline-kit-baseline-<version>.manifest.json # Per-file sha256 manifest.
offline-kit-baseline-<version>.summary.json # Run summary (CI-emitted).
offline-kit-baseline-<version>.manifest.signed.json # Only when signing is enabled — see §3.
2.1 Retrieving the artifact from a Gitea Actions run
- Open the latest successful scheduled run of the
Offline Kit Assemblyworkflow. - Scroll to Artifacts and download
offline-kit-<version>-<run-id>. - Verify the bundle’s sha256 matches the
archive.sha256field insummary.jsonbefore publication.
sha256sum offline-kit-baseline-2026.05.13.tar.gz
# Compare against:
jq -r '.archive.sha256' offline-kit-baseline-2026.05.13.summary.json
2.2 Publishing to operators
There are three supported distribution paths, in order of preference:
- Gitea release attachment. Attach the
.tar.gz+.manifest.json.summary.json(and.manifest.signed.jsonwhen present) to a Gitea release taggedoffline-kit/<version>. This is the recommended path because Gitea releases carry release notes + an immutable URL.
- Internal artifact mirror. Push to an internal HTTPS-only mirror the operator already trusts. The mirror MUST publish the per-file sha256 manifest alongside the archive.
- Physical media (air-gap) — for sites without operator-facing network egress. Burn the archive + manifest to optical media. Carry the
summary.jsonseparately (e.g., printed page) so the receiving operator has an out-of-band channel for the sha256 they can trust.
Cross-reference: the parallel release-manifest-verify-publish.yml lane handles the signed release-manifest publication for the full release artifacts (SBOMs, DSSE envelopes, OCI referrers). The offline-kit baseline lane documented here is the lightweight slice of the in-tree air-gap artifacts only.
2.3 Versioning convention
| Form | Use |
|---|---|
YYYY.MM.DD (default) | Scheduled weekly runs. The workflow auto-generates this from the run date in UTC. |
YYYY.MM.DD.HHMM | Out-of-band rotations on the same calendar day. Pass via workflow_dispatch input version. |
<custom> | Org-specific custom bundles. Prefix to avoid conflict with the canonical timeline (e.g., myorg.2026.05.13). |
The assembler accepts any string matching [A-Za-z0-9._-]+. The CI workflow validates this regex before invoking the assembler.
3. Key custody policy
The Offline Kit baseline manifest can optionally carry a DSSE-style signature so consumers can verify provenance independent of the distribution channel. Two key custody lanes are defined:
3.1 Dev key (CI dry-run only)
- Location:
src/__Tests/__Datasets/trust-store/scanner-plugins/dev/seed.hex(committed to the repo on purpose — see the README in that directory). - Use case: smoke-testing the signing pipeline in CI. The signature is produced and uploaded with the artifact under the
mode: "ci-dry-run"marker insummary.json. - NOT FOR PRODUCTION. A bundle signed with this key is recognisable by its key id (
stella-scanner-plugin-dev-…) and thesummary.signing.mode == "ci-dry-run"field. Air-gap site operators must reject any kit whosesummary.signing.mode != "production".
3.2 Production key (HSM-backed, two-person)
The production signing key is not yet provisioned. When ops adopts a custody policy, the following invariants apply:
- Key form: Ed25519 seed (32 bytes, RFC 8032 §5.1.5) — same format the dev key uses, same format the
Scanner.Plugin.SigningTool sign --key <path>flag accepts. - Storage: HSM-backed (preferred) or sealed file under two-person physical custody. NEVER on a shared filesystem.
- CI access: the seed is exposed to the Gitea Actions runner as the secret
OFFLINE_KIT_SIGNING_KEY. The workflow writes it toRUNNER_TEMPwithumask 077, signs, thenshred -us the file. - Two-person rotation: any rotation of the production key requires sign-off by two named custodians. The custody log lives at
docs/security/key-rotation-log.md(TODO — file lives there once the first production key is provisioned). - Audit log: the Gitea Actions run id and the new key id are appended to the custody log on each rotation.
- Trust-anchor distribution: the production public key (and any previous key still in the rotation window) is shipped to consumers out-of-band via the Gitea release page and the operator-facing documentation. Consumers must pin a trust-store snapshot and refuse signatures from unknown key ids.
Open decision (out of scope for Sprint 20260513_018): custody policy specifics (HSM vendor, two-person ceremony, rotation frequency). This doc fixes the contract; the operations team owns the policy. Until ops chooses, the
OFFLINE_KIT_SIGNING_KEYsecret remains unset and the workflow emits unsigned kits.
3.3 Signing roadmap
The Sprint 018 workflow has the signing infrastructure wired as a seam (setup-dotnet, build, key-staging, shred) but the actual signing call fails by design when invoked, because the current Scanner.Plugin.SigningTool validates its input as a ScannerPluginManifest (a different shape than the offline-kit baseline manifest). The future sprint that closes this gap will:
- Add a kit-shaped DSSE signer that uses the same Ed25519 primitives.
- Flip the workflow’s default
enable_signinginput fromfalsetotrue. - Replace the “Sign manifest (PLACEHOLDER)” step body with the real signer invocation.
Until that sprint lands, Offline Kit bundles ship unsigned and consumers verify via the per-file sha256 manifest exclusively (see §4).
4. How consumers verify
Three verification layers are available; each is independently useful.
4.1 Archive sha256
The simplest check: the operator who downloaded the kit confirms its sha256 against the value quoted in the Gitea release page, the summary.json file, or an out-of-band channel.
sha256sum offline-kit-baseline-2026.05.13.tar.gz
Expected output matches the archive.sha256 field from the run’s summary.json.
4.2 Per-file manifest
Once extracted, the receiving operator can re-hash every file in the archive and compare against the in-archive manifest (0-manifest.json). The bash recipe is in offline-kit-assembly.md§6 and reproduced here for clarity:
mkdir -p /tmp/kit && tar -xzf offline-kit-baseline-2026.05.13.tar.gz -C /tmp/kit
cd /tmp/kit
# Spot-check
sha256sum datasets/trust-store/scanner-plugins/dev/index.json
# -> compare with "sha256" entry in 0-manifest.json for the same path
# Full pass
python3 - <<'PY'
import hashlib, json, pathlib, sys
m = json.load(open('0-manifest.json'))
bad = 0
for entry in m['files']:
p = pathlib.Path(entry['path'])
h = hashlib.sha256(p.read_bytes()).hexdigest()
if h != entry['sha256']:
print(f'MISMATCH {p}: got {h} expected {entry["sha256"]}', file=sys.stderr)
bad += 1
print(f'{len(m["files"])} files; {bad} mismatches')
sys.exit(1 if bad else 0)
PY
This layer alone is sufficient for an air-gap operator who trusts the distribution channel (e.g., physical media + an out-of-band sha256 quote).
4.3 Signed manifest (future, see §3.3)
When the kit-shaped DSSE signer lands, the receiving operator will run:
dotnet run --project src/Scanner/__Libraries/StellaOps.Scanner.Plugin.SigningTool \
-- verify \
--manifest offline-kit-baseline-2026.05.13.manifest.signed.json \
--trust-store /etc/stellaops/offline-kit/trust-store
The trust-store is the operator’s pinned snapshot of the production public key(s) (§3.2). Until the kit-shaped signer lands, this verb fails on offline-kit manifests by design — consumers must rely on §4.1 + §4.2.
5. Failure modes and incident response
| Symptom | Cause | Action |
|---|---|---|
| Workflow fails on the determinism harness step | Assembler regression — outputs are no longer byte-stable | File a Sprint ticket immediately; do not publish the kit. The harness is the primary correctness gate. |
| Workflow fails on the assembly step | Dataset enumeration error, missing file, runner I/O error | Inspect logs; re-trigger if transient; fix the upstream dataset PR if structural. |
| Consumer’s per-file hash check fails | Transport corruption or tampering | Re-download from the canonical release page; if the second copy also mismatches, treat as a supply-chain incident and escalate to the Security Guild. |
summary.signing.mode == "ci-dry-run" in a production-tagged release | The production signing secret was missing during the run | Reject the kit. Re-trigger after provisioning OFFLINE_KIT_SIGNING_KEY. |
summary.signing.mode != null but manifest.signed.json missing | Signing step crashed mid-flight | Reject the kit; re-trigger. |
6. Cross-references
offline-kit-assembly.md— the assembler contract this runbook drives.secrets-bundle-rotation.md— sibling rotation discipline; the secret-bundle distribution path piggy-backs on this Offline Kit flow when air-gap operators take both at once.SPRINT_20260513_018_DevOps_offline_kit_ci_workflow_rotation.md— the sprint that productionised this lane.SPRINT_20260513_011_DevOps_offline_kit_assembly_baseline.md— the prior baseline sprint that landed the assembler + determinism harness..gitea/workflows/offline-kit-assembly.yml— the CI workflow..gitea/workflows/release-manifest-verify-publish.yml— the parallel release-manifest lane (full release artifacts, not the baseline).
