Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

120 changes: 97 additions & 23 deletions OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ SPDX-FileCopyrightText: 2026 Mohamed Hammad

Operational procedures for Loran maintainers. This document is the
canonical reference for the **trust-pinned publisher key** lifecycle —
how to plan a normal rotation, run a parallel-key transition window,
and recover from an emergency compromise.
how the upstream catalog is built and signed (§2), how to plan a normal
rotation, run a parallel-key transition window, and recover from an
emergency compromise.

It resolves **PRD Open Question 1** ("How do we rotate the publisher's
signing key?") and operationalises **Spec §2 decision #6** (minisign +
Expand All @@ -17,7 +18,7 @@ ed25519 over a trust-pinned key baked into the binary).
## Audience

- Loran release engineers: every section.
- Spacecraft Software Operations: §3 (emergency rotation).
- Spacecraft Software Operations: §4 (emergency rotation).
- Downstream operators (Bravais OS, Ferrite OS, …): §1.4 reading list
so an in-the-wild compromise is recognisable from the user side.

Expand All @@ -33,21 +34,21 @@ consulted by `signing::verify_any` during every `loran update`.
For releases that ship a single active publisher key, the slice is
length-one. For rotation windows, the slice contains **both** the
outgoing and incoming keys — `verify_any` accepts a tarball whose
signature matches *any* of them (§2.2).
signature matches *any* of them (§3.2).

### 1.2 Why baked-in, not fetched

A trust-pinned key that's fetched at runtime trades cryptographic
guarantee for first-use trust. Loran refuses that trade: the only way
to change the trust root is to ship a new Loran release. The
consequence — and the reason §2 and §3 exist — is that the rotation
consequence — and the reason §3 and §4 exist — is that the rotation
procedure must be planned well before the trust root has to change.

### 1.3 Key inventory

| Constant in `loran-core::pipeline` | Used for | Notes |
|------------------------------------|--------------------|----------------------------------|
| `PUBLISHER_PUBLIC_KEY` | Upstream pages tarball | Placeholder; real key lands at Sub-phase 2D launch |
| `PUBLISHER_PUBLIC_KEY` | Upstream pages tarball | Placeholder; replace with the real key at first launch (§2.4) |

When parallel keys are active the `default_publisher` constructor
returns both via the `LORAN_PAGES_PUBLIC_KEY` env var (comma-separated)
Expand All @@ -57,21 +58,90 @@ or by editing the embedded constant set to a `Vec` of base64 strings.

- An unexpected `TARBALL_VERIFY_FAILED` (exit 11) from `loran update`
is the first signal of a key mismatch. Compare the running Loran
version against the [release advisories](#5-release-advisories) —
version against the [release advisories](#6-release-advisories) —
a parallel-key window means a stale Loran has been left running past
the cut-over.
- A signed Spacecraft Software release announcement is the only authoritative
source for the active publisher key. Operators should not import
keys obtained any other way.

## 2. Normal rotation procedure
## 2. Producing & publishing the pages tarball

The upstream catalog is authored in the **`loran-pages`** content repo
(<https://github.com/Spacecraft-Software/loran-pages>), not in this
repository. That repo's CI is the **producer** side of the
`loran update` mechanism; this section documents how it builds and signs
the artifacts that `update_pages` consumes.

### 2.1 Artifacts and endpoint

`scripts/build-pages.sh` in `loran-pages` produces three files under
`dist/`, and `publish.yml` uploads them to the rolling `pages-latest`
GitHub Release:

| Asset | Role |
|-------|------|
| `pages.tar.gz` | gzip tarball; category dirs at the archive root, extracted verbatim into `$XDG_DATA_HOME/loran/pages/`. |
| `pages.json` | manifest — `{version, etag, sha256, size}`. |
| `pages.tar.gz.minisig` | detached minisign signature over `pages.tar.gz`. |

They are served from
`https://github.com/Spacecraft-Software/loran-pages/releases/latest/download/<asset>`
— exactly the URLs baked into
`loran-core::pipeline::PUBLISHER_PAGES_{MANIFEST,TARBALL,SIG}_URL`. The
build is deterministic (sorted entries, pinned `mtime`, `gzip -n`), so
unchanged content yields identical bytes; the published asset's ETag
stays stable across rebuilds and keeps client `304`s cheap.

### 2.2 Where the signing key lives

The publisher **secret** key is held in the Spacecraft Software release
vault (§1.1) and mirrored into the `loran-pages` repository's Actions
secrets so `publish.yml` can sign unattended:

- `MINISIGN_SECRET_KEY` — the minisign secret-key file contents.
- `MINISIGN_PASSWORD` — its password (may be empty).

The matching **public** key is baked into the Loran binary as
`PUBLISHER_PUBLIC_KEY` (§1.1). The two are always a pair: changing
either is a key rotation (§3 / §4), never an isolated edit.

### 2.3 Publish cadence

`publish.yml` runs on every push to `loran-pages` `main` that touches
`pages/**` (and on manual dispatch). It validates (`loran validate
pages/`), packs, signs, and rolls the `pages-latest` release. Clients
pick the change up on their next `loran update` — manual, or the opt-in
auto-update once the cached catalog passes its staleness interval.

### 2.4 First-launch checklist

The pipeline currently ships a development **placeholder** key (§1.3,
§5). To go live:

1. Generate the production keypair:
`nix shell nixpkgs#minisign -c minisign -G`
2. Store the secret key in the release vault; add `MINISIGN_SECRET_KEY`
and `MINISIGN_PASSWORD` to the `loran-pages` repo's Actions secrets.
3. Replace `PUBLISHER_PUBLIC_KEY` in `loran-core::pipeline` with the new
public key (and keep `signing::tests::TEST_PUBLIC_KEY` distinct —
§5).
4. Cut a Loran release carrying the real key, so installed clients trust
the publisher (the baked placeholder never validated a real tarball,
so there is no breakage for existing users).
5. Trigger `publish.yml` (push to `loran-pages`, or manual dispatch) to
cut the first signed `pages-latest` release.
6. Verify end-to-end against a clean `$XDG_DATA_HOME`: `loran update`,
then `loran show <a-tool>`.

## 3. Normal rotation procedure

Loran rotates the publisher key on a **planned annual cadence** as the
default. The rotation is announced in advance, executed during a
parallel-key transition window, and concluded with a release that
drops the outgoing key.

### 2.1 Pre-rotation (T-30 days)
### 3.1 Pre-rotation (T-30 days)

1. Generate the new keypair with the upstream-blessed `minisign`
binary (Nix-provided to avoid supply-chain drift):
Expand All @@ -81,7 +151,7 @@ drops the outgoing key.
3. Open a tracking issue. Announce the planned cut-over date and the
parallel-key window length (≥14 days recommended).

### 2.2 Parallel-key window (T-0 → T+14d)
### 3.2 Parallel-key window (T-0 → T+14d)

1. Cut a Loran release whose embedded `PUBLISHER_PUBLIC_KEY` array
contains **both** the outgoing and incoming public keys. The
Expand All @@ -94,7 +164,7 @@ drops the outgoing key.
4. Publish a release advisory naming the parallel-key window's start
and the announced cut-over date.

### 2.3 Cut-over (T+14d)
### 3.3 Cut-over (T+14d)

1. Cut a follow-up Loran release whose embedded
`PUBLISHER_PUBLIC_KEY` array contains **only** the incoming key.
Expand All @@ -104,20 +174,20 @@ drops the outgoing key.
`verify_any → SignError::Mismatch` from the cut-over release
forward, which surfaces as exit code 11 (`TARBALL_VERIFY_FAILED`).

### 2.4 Post-rotation
### 3.4 Post-rotation

1. Destroy the outgoing secret key from the release vault. The
advisory should reference the destruction timestamp.
2. Schedule the next rotation's tracking issue.

## 3. Emergency rotation (compromise)
## 4. Emergency rotation (compromise)

Emergency rotation is used when the outgoing key is known or
strongly suspected to be compromised. The procedure differs from a
normal rotation in two ways: there is no parallel-key window, and
the advisory is published before the new release ships.

### 3.1 Containment (T-0, hour 1)
### 4.1 Containment (T-0, hour 1)

1. Pause every signing pipeline. Do **not** sign any further tarball
with the compromised key.
Expand All @@ -126,9 +196,9 @@ the advisory is published before the new release ships.
notice.
3. Open an incident channel; assign an incident commander.

### 3.2 Replacement (hours 2-24)
### 4.2 Replacement (hours 2-24)

1. Generate a new keypair (§2.1).
1. Generate a new keypair (§3.1).
2. Cut an emergency Loran release whose embedded
`PUBLISHER_PUBLIC_KEY` array contains **only** the new key. The
compromised key is omitted — every tarball it signed becomes
Expand All @@ -137,7 +207,7 @@ the advisory is published before the new release ships.
4. Update the advisory with the new release version and the new
public key fingerprint.

### 3.3 Recovery (days 2-7)
### 4.3 Recovery (days 2-7)

1. Operators upgrade Loran to the emergency release; this is the
only safe way to obtain a tarball signed by the new key.
Expand All @@ -146,19 +216,19 @@ the advisory is published before the new release ships.
3. Destroy the compromised secret key if it hasn't already been;
verify backups don't retain it.

## 4. Test fixtures and key reuse
## 5. Test fixtures and key reuse

- `crates/loran-core/src/signing.rs::tests::TEST_PUBLIC_KEY` is a
test-only key with a documented purpose. **It must never be the
active publisher key.** Phase 2 reuses the same string as the
placeholder `PUBLISHER_PUBLIC_KEY` precisely so an operator who
accidentally runs `loran update` against the placeholder URL gets a
clean diagnostic rather than a surprising decode failure.
- When the real publisher pipeline launches (Sub-phase 2D), the
release engineer must swap `PUBLISHER_PUBLIC_KEY` to the real key
and update `signing::tests::TEST_PUBLIC_KEY` to remain distinct.
- At first launch (§2.4), the release engineer must swap
`PUBLISHER_PUBLIC_KEY` to the real key and update
`signing::tests::TEST_PUBLIC_KEY` to remain distinct.

## 5. Release advisories
## 6. Release advisories

All publisher-key advisories — planned and emergency — are published
to:
Expand All @@ -178,7 +248,7 @@ Each advisory must include:
- Recommended operator action (parallel-key window timeline, or
upgrade-now-and-halt-updates).

## 6. References
## 7. References

- Spec §2 decision #6 — trust-pinned key constraint.
- Spec §11 — tldr-pages explicitly *not* covered by this procedure
Expand All @@ -188,3 +258,7 @@ Each advisory must include:
primitive.
- `loran-core::pipeline::UpdateOpts::public_keys` — the runtime
trust-pinned set consulted by `update_pages`.
- PRD NG-09 — the publisher pipeline this section realizes (originally
scoped as a separate, out-of-PRD project).
- `loran-pages` repo (`scripts/build-pages.sh`, `.github/workflows/{validate,publish}.yml`)
— the producer implementation described in §2.
2 changes: 1 addition & 1 deletion crates/loran-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,4 @@ pub use search::{ScoredMatch, SearchResult, resolve_search};
pub use show::{BodyBlock, IntroBlock, ShowResult, resolve_show, resolve_show_with_tldr};
pub use signing::{SignError, verify as verify_minisign, verify_any as verify_minisign_any};
pub use tldr::{DEFAULT_PLATFORMS, NoTldr, TldrCache, TldrLookup};
pub use xdg::{cache_home, data_home};
pub use xdg::{cache_home, config_home, data_home};
43 changes: 27 additions & 16 deletions crates/loran-core/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,30 +51,41 @@ pub const TLDR_PAGES_URL: &str = "https://tldr-pages.github.io/assets/tldr.zip";

/// Publisher manifest URL.
///
/// **Placeholder.** The real upstream CDN endpoint is selected when
/// the publisher pipeline (Sub-phase 2D) launches. Until then this URL
/// will fail a manifest fetch — that's by design, so a `loran update`
/// against an un-launched publisher fails loud rather than silently.
/// Served from the `Spacecraft-Software/loran-pages` content repo as a
/// GitHub Release asset on the moving `latest` release, so the URL
/// always resolves to the most recently published catalog (the same
/// `releases/latest/download/…` pattern tldr-pages clients use). The
/// 302 redirect to GitHub's asset CDN carries an `ETag`, which the
/// conditional-GET path in [`FetchClient::fetch_manifest`] relies on.
///
/// Until the publisher pipeline cuts its first release this asset 404s
/// — by design, so a `loran update` against an un-launched publisher
/// fails loud rather than silently.
pub const PUBLISHER_PAGES_MANIFEST_URL: &str =
"https://Loran.SpacecraftSoftware.org/pages/v1/pages.json";
"https://github.com/Spacecraft-Software/loran-pages/releases/latest/download/pages.json";

/// Publisher tarball URL. Placeholder; see [`PUBLISHER_PAGES_MANIFEST_URL`].
/// Publisher tarball URL. See [`PUBLISHER_PAGES_MANIFEST_URL`].
pub const PUBLISHER_PAGES_TARBALL_URL: &str =
"https://Loran.SpacecraftSoftware.org/pages/v1/pages.tar.gz";
"https://github.com/Spacecraft-Software/loran-pages/releases/latest/download/pages.tar.gz";

/// Publisher detached signature URL (minisign `.minisig`).
/// Placeholder; see [`PUBLISHER_PAGES_MANIFEST_URL`].
pub const PUBLISHER_PAGES_SIG_URL: &str =
"https://Loran.SpacecraftSoftware.org/pages/v1/pages.tar.gz.minisig";
/// See [`PUBLISHER_PAGES_MANIFEST_URL`].
pub const PUBLISHER_PAGES_SIG_URL: &str = "https://github.com/Spacecraft-Software/loran-pages/releases/latest/download/pages.tar.gz.minisig";

/// Publisher's minisign public key.
///
/// **Placeholder — development key, NOT for production use.** Real
/// upstream releases replace this with the publisher's actual public
/// key as part of the Sub-phase 2D launch. Until then this constant
/// is the same test key as `signing::tests::TEST_PUBLIC_KEY`, embedded
/// here so the orchestration code compiles and unit tests can exercise
/// the verify step.
/// **Placeholder — development key, NOT for production use.**
///
/// TODO(launch): replace with the real `loran-pages` publisher public
/// key before the first signed release. Generate the keypair with
/// `nix shell nixpkgs#minisign -c minisign -G`, paste the public half
/// here, and store the secret key + password as the `MINISIGN_SECRET_KEY`
/// / `MINISIGN_PASSWORD` secrets in the `loran-pages` repo (see
/// `OPERATIONS.md` → "Producing & publishing the pages tarball").
/// Until then this constant is the same test key as
/// `signing::tests::TEST_PUBLIC_KEY`, embedded here so the
/// orchestration code compiles and unit tests can exercise the verify
/// step.
///
/// Key rotation: a new Loran release ships a new value here. Older
/// binaries fetching tarballs signed by a rotated key fail with
Expand Down
11 changes: 11 additions & 0 deletions crates/loran-core/src/xdg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ pub fn cache_home() -> Option<PathBuf> {
env_path("XDG_CACHE_HOME").or_else(dirs::cache_dir)
}

/// Resolve the base config-home directory (where Loran reads its
/// `config.toml`).
///
/// Order of precedence:
/// 1. `$XDG_CONFIG_HOME` (any platform, when non-empty).
/// 2. `dirs::config_dir()` (native convention per platform).
#[must_use]
pub fn config_home() -> Option<PathBuf> {
env_path("XDG_CONFIG_HOME").or_else(dirs::config_dir)
}

fn env_path(key: &str) -> Option<PathBuf> {
std::env::var_os(key)
.map(PathBuf::from)
Expand Down
1 change: 1 addition & 0 deletions crates/loran/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ loran-render = { path = "../loran-render", version = "0.4.0" }
loran-tui = { path = "../loran-tui", version = "0.4.0" }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
walkdir = { workspace = true }
Expand Down
Loading
Loading