cairn-mod 1.0.0

Lightweight, Rust-native ATProto labeler
Documentation
# Cairn

A lightweight, Rust-native [ATProto](https://atproto.com) labeler —
single binary, SQLite-backed, designed for small and mid-scale
community moderation.

[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](#license)
[![MSRV: 1.88](https://img.shields.io/badge/MSRV-1.88-informational.svg)](Cargo.toml)
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md)

<!-- TODO (#27 CI hardening): GitHub Actions badge once workflows exist. -->
<!-- TODO (#28 release workflow): crates.io + docs.rs badges once published. -->

## What is Cairn?

Cairn is a standalone ATProto labeler server. It publishes a
[`app.bsky.labeler.service`](https://atproto.com/lexicons/app-bsky-labeler)
record, signs labels per the ATProto spec, accepts user reports, and
exposes an admin XRPC surface for moderators to act on them. It
exists because the ecosystem has Ozone (heavy, TypeScript,
Postgres-backed, opinionated web UI) and Skyware's labeler library
(minimal, no report intake, no audit trail), with a gap between them
for operators who want something compact but production-grade. Cairn
is deliberately smaller than Ozone and deliberately more complete
than Skyware; it does not try to be either.

## Status

**v1 is in development.** The technical surface is code-complete —
see the [tracker](https://github.com/skydeval/cairn-mod/issues) for
remaining docs + release work. v1.1 plans (review queue, richer
admin API, key rotation) live in the design doc's §18 roadmap.

## Quickstart

Target: a running `cairn serve` behind a reverse proxy, published
service record, responding to `GET /.well-known/did.json`.

### Prerequisites

- **Rust 1.88+** — install via [rustup]https://rustup.rs.
- **A DID for the labeler** — either `did:web:your.host` or
  `did:plc:...`. How to obtain one is out of scope; the design doc
  §5.1 has the context.
- **A PDS account for the labeler** — the labeler DID must have a
  live repo on a PDS (self-hosted or bsky.social). Publishing the
  service record writes to that repo.
- **A TLS-terminating reverse proxy** — nginx or Caddy, see
  [`contrib/`]contrib/ for templates.

### 1. Install

```
cargo install cairn-mod
```

Produces a binary named `cairn`. On cargo's default install prefix,
that ends up at `~/.cargo/bin/cairn`; operators typically copy it to
`/usr/local/bin/cairn` for systemd deployment (see
[`contrib/README.md`](contrib/README.md)).

### 2. Generate a signing key

Cairn expects a 64-hex-char file containing a secp256k1 private key.
Either of these produces one:

```
# Python (simplest):
python3 -c 'import secrets; print(secrets.token_hex(32))' > signing-key.hex

# Or openssl:
openssl ecparam -name secp256k1 -genkey -noout \
  | openssl ec -text -noout 2>/dev/null \
  | awk '/priv:/{flag=1;next} /pub:/{flag=0} flag' \
  | tr -d ': \n' > signing-key.hex
```

Then lock down permissions — §5.1 requires mode `0600` owned by the
running user, and `cairn serve` refuses to start otherwise:

```
chmod 600 signing-key.hex
sudo chown cairn:cairn signing-key.hex       # if running under systemd
sudo mv signing-key.hex /var/lib/cairn/signing-key.hex
```

Publish the matching public key in the labeler's DID document at
verification method `#atproto_label`. Consumers verifying Cairn's
labels resolve the DID and extract this key.

### 3. Configure

Minimal `cairn.toml`:

```toml
# Labeler identity (§5.1). The DID must resolve to a document
# containing your signing key at verification method #atproto_label.
service_did      = "did:web:labeler.example"
# Public HTTPS URL consumers use to reach this labeler. Emitted in
# /.well-known/did.json as the AtprotoLabeler serviceEndpoint.
service_endpoint = "https://labeler.example"
# SQLite file. Created on first run; parent dir must exist.
db_path          = "/var/lib/cairn/cairn.db"
# Hex-encoded 32-byte secp256k1 key. Must be mode 0600 owned by
# the running user — Cairn refuses to start otherwise.
signing_key_path = "/var/lib/cairn/signing-key.hex"
# bind_addr defaults to "127.0.0.1:3000"; override if running
# without a reverse proxy on the same host.

# The app.bsky.labeler.service record `cairn publish-service-record`
# emits to your PDS. Lists what labels you declare and how consumers
# should render them.
[labeler]
label_values = ["spam"]

[[labeler.label_value_definitions]]
identifier      = "spam"
severity        = "alert"            # inform | alert | none
blurs           = "none"             # content | media | none
default_setting = "warn"             # ignore | warn | hide
locales         = [
  { lang = "en", name = "Spam", description = "Unsolicited promotional content." },
]

# Where the operator (= the labeler DID) authenticates to publish the
# service record. Separate from moderators authenticating to Cairn
# (§5.3) — different identity, different file.
[operator]
pds_url      = "https://bsky.social"
session_path = "/var/lib/cairn/operator-session.json"
```

See [`contrib/`](contrib/) for the systemd + Caddy + nginx templates
that plug into these paths.

### 4. Bootstrap (one-time per deployment)

Authenticate to the operator's PDS and publish the service record:

```
cairn operator-login --handle labeler.example.com --config /etc/cairn/cairn.toml
cairn publish-service-record --config /etc/cairn/cairn.toml
```

The publish step is idempotent — re-running with unchanged config is
a no-op.

### 5. Run

```
# Foreground:
cairn serve --config /etc/cairn/cairn.toml

# Or via systemd (contrib/):
sudo systemctl enable --now cairn
```

### 6. Verify

```
curl -sSL https://labeler.example/.well-known/did.json | jq '.verificationMethod[].id'
```

Should return `"did:web:labeler.example#atproto_label"` (or the
suffixed forms during v1.1 key rotation).

## Production Checklist

Walk through this before pointing real subscribers at the instance.
Each item links the relevant design-doc section for deeper context.

### Transport ([§F13]cairn-design.md#f13-single-binary--sqlite-deployment)

- [ ] **TLS terminates at the reverse proxy**, not Cairn. `cairn
  serve` binds HTTP only (default `127.0.0.1:3000`) and assumes a
  TLS-terminating proxy fronts it. See
  [`contrib/`]contrib/ for Caddy and nginx templates.
- [ ] **HSTS header** on responses (`Strict-Transport-Security:
  max-age=31536000; includeSubDomains`). Set at the proxy; the
  contrib templates ship it.

### Rate limits ([§F13]cairn-design.md#f13-single-binary--sqlite-deployment numbers, enforced at reverse proxy)

- [ ] **`createReport`** per-IP: burst 3, rate 10 per hour. Enforced
  by `contrib/nginx/cairn.conf` out of the box; Caddy requires the
  `caddy-ratelimit` third-party module — see
  [`contrib/caddy/Caddyfile`]contrib/caddy/Caddyfile for the
  commented stanza and install pointer.
- [ ] **`subscribeLabels`** per-IP: 8 concurrent connections.
  Enforced alongside the createReport limit in the same contrib
  configs.

### Secrets ([§5.1]cairn-design.md#51-labeler-service-identity, [§5.3]cairn-design.md#53-cli-ergonomics)

- [ ] **Signing key file** at mode `0600`, owned by the running
  user. Cairn's `credential_file::check_mode_and_owner` refuses
  wider permissions or foreign ownership at startup.
- [ ] **Signing key file NEVER committed to version control.** A
  signing key in git history is a compromise even after deletion.
- [ ] **Signing key material NOT delivered via env var.** The
  `CAIRN_SIGNING_KEY` env var is explicitly rejected by the
  loader (`SIGNING_KEY_ENV_REJECTED` constant) — the guardrail
  exists to prevent an "ergonomics" PR later adding a parallel
  unsafe path.
- [ ] **Operator session file** (written by `cairn operator-login`)
  at `0600` owned by the running user. Treat it as equivalent to
  your PDS app password — anyone with read access can push records
  to the labeler's PDS repo until the session expires.

### Key lifecycle ([§4.1.6]cairn-design.md#41-out-of-scope-threats, [§12]cairn-design.md#12-security-considerations)

- [ ] **Signing key is permanent for v1.** Rotation is v1.1
  scope. Plan the host, permissions, and backup accordingly — a
  v1 key lives as long as the labeler identity.
- [ ] **Do NOT remove the labeler signing key from the DID
  document.** Every historical label Cairn has signed stops
  verifying at consumers. Catastrophic and not undoable.

### Backup

- [ ] **SQLite database** (`db_path`) — contains labels, reports,
  audit log, moderators, single-instance lease state. Regular
  backups via `sqlite3 .backup` or filesystem snapshot.
- [ ] **Signing key file** (`signing_key_path`) — losing it means
  losing the labeler identity. Back up encrypted, store offline.
- [ ] **Session files are NOT backup-worthy.** Operator and
  moderator sessions are re-created by running `cairn
  operator-login` / `cairn login`.

### Moderator management

v1 has no dedicated CLI or admin XRPC for moderator membership.
Manage via direct SQL against the `moderators` table:

```sql
-- Grant mod role:
INSERT INTO moderators (did, role, added_at)
VALUES ('did:plc:example', 'mod', strftime('%s','now') * 1000);

-- Grant admin role:
INSERT INTO moderators (did, role, added_at)
VALUES ('did:plc:example', 'admin', strftime('%s','now') * 1000);

-- Revoke (any role):
DELETE FROM moderators WHERE did = 'did:plc:example';
```

`cairn moderator {add,remove,list}` subcommands are v1.1 scope
(§F9).

### Single instance per DID ([§F5]cairn-design.md#f5-label-persistence-with-monotonic-sequence)

- [ ] **Only one `cairn serve` against a given DID at a time.**
  Cairn enforces this with a SQLite-backed lease; the loser of a
  startup race exits with `LEASE_CONFLICT` (exit code 11). The
  contrib systemd unit sets `RestartPreventExitStatus=11` so
  systemd doesn't restart-loop.
- [ ] **Even with the lease disabled, two instances against the
  same DB would corrupt the sequence space.** Don't attempt it.

### Monitoring (v1 surface is minimal)

- [ ] **systemd status + journalctl** — errors and panics land
  here. The contrib unit sets `StandardOutput=journal`.
- [ ] **Disk usage on the `db_path` partition** — Cairn has an
  app-level disk guard for the report path, but OS-level
  monitoring catches everything.
- [ ] **TLS certificate expiry at the reverse proxy** — Caddy
  auto-renews via ACME; nginx + certbot needs its own cron check.

Cairn v1 exposes **no dedicated `/health` or `/ready` endpoint**.
"Process up + `/.well-known/did.json` returns 200" is the
available liveness signal. A dedicated health endpoint is tracked
for a future version.

## Trust-chain disclosures

Operators AND subscribers should understand what Cairn's protocol
guarantees and what it doesn't. These are v1 properties, documented
in [§4.2](cairn-design.md#42-operator-trust-trust-chain-readme-audience)
of the design doc and summarized here per §14's "prominently
placed" directive.

1. **Label trust is operator trust.** A subscriber to this
   labeler's DID is implicitly trusting the current and past
   judgment of whoever controls that DID. If the operator
   silently swaps intent (becomes malicious, sells the DID, is
   compromised) there is no protocol-level mechanism for
   subscribers to detect this.

2. **Historical labels are forgeable by a malicious operator with
   DB access.** v1's audit log records who/when/why at the
   application layer but isn't cryptographically linked to the
   labels table. An operator with direct SQLite access can
   rewrite history. v1.1's hash-chained audit log is a
   prerequisite (but not sufficient) for historical-label
   integrity.

3. **Single operator per instance is a single point of
   compromise.** Operators concerned about unilateral
   label-history tampering should evaluate this limitation
   against their threat model. Mitigations (transparency logs,
   hash-chained audit) are tracked for future versions; specific
   mechanics are not yet finalized.

## Architecture

- **Single-writer task** (§F5) owns all write operations through an
  mpsc channel — sequence monotonicity, cts clamping, and signing
  all happen in one place.
- **Signed labels** per §6.2 — DAG-CBOR canonical encoding,
  ES256K with RFC 6979 deterministic nonces, low-S enforced at
  emission. Parity with `@atproto/api` is pinned by a fixture
  corpus in `tests/`.
- **Single-instance lease** (§F5) prevents two Cairn processes from
  signing labels against the same DID. Second `cairn serve` exits
  with a dedicated `LEASE_CONFLICT` code so systemd doesn't
  restart-loop.
- **Admin XRPC** lives under `tools.cairn.admin.*` — the custom
  lexicons are embedded in the binary and served at
  `/.well-known/lexicons/tools/cairn/admin/`.

Everything deeper is in the [design doc](cairn-design.md) —
threats, cryptographic details, schema migration policy, v1.1
roadmap.

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md) for build + test + PR
workflow. Participation is governed by the
[Contributor Covenant](CODE_OF_CONDUCT.md).

## Security

Vulnerabilities go to the private channel in
[SECURITY.md](SECURITY.md) — **not** public issues.

## License

Dual-licensed under [MIT](LICENSE-MIT) or
[Apache 2.0](LICENSE-APACHE) at your option. Contributions are
accepted under the same terms.