# ROBOT.md v0.2 Design — Signing, Registry Ingestion, Tamper-Evidence

**Status:** Draft design — awaiting user review
**Author:** Craig (craigm26) + Claude (drafting)
**Target release:** robot-md v0.2 / CLI 0.2.0
**Supersedes (scope):** `SECURITY.md` → "Known v0.1 Limitations"
**Last updated:** 2026-04-17

---

## 0. Preamble — what this document is and is not

This is a **design document only.** It commits to zero code. Every
load-bearing cryptographic and registry decision below has at least one
unresolved question that requires explicit user sign-off before
implementation begins.

The v0.1.1 CLI shipped today fixed two silent-failure bugs (schema
packaging, Cloudflare Pages SPA fallback). Those were small, reversible,
and verifiable. v0.2 is different: it introduces long-lived public keys,
persistent manifests served to third-party validators, and a claim of
tamper-evidence that the ecosystem will rely on. Shipping any piece of
that wrong — or rushed — degrades the trust posture of every robot that
has already adopted v0.1. So this document is deliberately over-specified
for its length: we want the reader to catch design flaws in prose, not
in a post-mortem.

Implementation begins only after the sections marked
**"Decision required"** are resolved in writing (issue, PR comment, or
amendment to this doc).

---

## 1. Goals and non-goals for v0.2

### Goals

1. **Signed manifests.** A `ROBOT.md` can be cryptographically signed by
   its owner such that a third party can verify the manifest has not
   been altered since it was published.
2. **Registry-hosted retrieval.** A signed `ROBOT.md` can be fetched
   from `rcan.dev` via the RRN, so consumers do not depend on the
   owner's personal hosting being up.
3. **Key-binding at mint time.** The public key that verifies a
   manifest is bound to the RRN when the RRN is issued, not TOFU'd at
   first upload. This prevents the "I got there first" attack on a
   newly minted RRN.
4. **Tamper-evidence for key→RRN bindings.** A third party can check
   whether the public key associated with an RRN today is the same one
   that was bound at mint time, without relying solely on the RRF
   saying so.
5. **Quantum-hedging option.** The format leaves room for a post-quantum
   signature algorithm alongside Ed25519 without a breaking change.

### Non-goals

- Signing the *code* inside OpenCastor (that is OpenCastor's concern).
- A general-purpose PKI. We issue one key per RRN, owned by the RRN
  holder; that is all.
- Mandatory signing in v0.2. Unsigned manifests remain valid at
  Tier 0 — signing unlocks verified-tier display on rcan.dev, not basic
  operation.
- Revocation-at-distance. If a key is compromised, the owner rotates
  the key by making a signed rotation statement from the old key. No
  CRL, no OCSP. (Revocation without the old key requires an
  out-of-band recovery flow; see §9.)

---

## 2. Threat model — what changes from v0.1

v0.1's threat model is "trusted operator + trusted planner." v0.2
*expands the trust surface outward* to third parties who consume a
ROBOT.md they did not author. The attackers we now care about:

| Attacker | Goal | Defense in this design |
|---|---|---|
| Impersonator with write access to owner's webhost | Serve a malicious ROBOT.md under the owner's URL | Signature on the manifest; consumer verifies against RRN-bound pubkey |
| RRN-squatter | Mint an RRN before the legitimate owner and hold it | Key bound at mint time — squatter's key ≠ owner's key, and RRF can refuse mint without a key |
| Compromised RRF operator | Silently swap the key bound to an RRN | Accepted residual risk in v0.2 (see §6). Transparency-log upgrade path documented for v0.3+ |
| Downgrade attacker | Serve an old, benign-looking signed manifest to hide a newer signed warning | Monotonic `manifest_version` in signed payload, enforced by registry on upload |
| Algorithm-break attacker | Break Ed25519 in N years and forge historical manifests | Algorithm tag in signature format allows migration; optional PQ hedge (§4) |

What we still do **not** defend against:

- Compromise of the owner's private key. (They need to rotate it.)
- Compromise of the owner's signing machine at the moment of signing.
- A planner (Claude Code or OpenCastor) that chooses to ignore the
  signature. Verification is advisory; consumption is the consumer's
  choice.

---

## 3. Format decisions

### 3.1 Detached signature, not embedded

**Decision:** signatures live in a **separate file** next to the
manifest, not inside the manifest YAML frontmatter.

```
bob.ROBOT.md
bob.ROBOT.md.sig     # base64-encoded signature envelope (§4)
```

Rationale:

- Embedding forces a "zero out the signature field, sign, put it back"
  canonicalization dance. That dance is a common source of
  signature-verification bugs (YAML round-tripping is not
  byte-stable).
- Detached signing lets us sign the **exact bytes** of the ROBOT.md
  file as they exist on disk / as served by the registry. No
  canonicalization. No "which YAML dumper did you use."
- The registry already hosts files; hosting a second file next to each
  manifest is trivial.

**Consequence for the spec:** the frontmatter *does* gain a
`metadata.key_fingerprint` field (see §3.3) so a consumer can tell
*which* key should have signed this file without having to fetch
something separate. The fingerprint is not the signature; it is
metadata that lets the verifier refuse a manifest signed by an
unexpected key.

### 3.2 What exactly gets signed

The signature covers **the raw UTF-8 bytes of `bob.ROBOT.md`**, start
to finish, including frontmatter and prose. No exclusions. No
normalization. If a byte changes, the signature fails.

This is deliberate and restrictive. It means: re-rendering the
markdown, stripping trailing whitespace, or changing YAML quote style
will all invalidate the signature. Good. The owner signs what they
intend to publish, exactly.

Signing consumes the file via a streaming SHA-512 (Ed25519's internal
hash) — no full load required for large manifests.

### 3.3 Frontmatter additions

```yaml
metadata:
  # existing fields...
  signature:
    algorithm: "ed25519"          # or "pqc-hybrid-v1" (§4.2)
    key_fingerprint: "sha256:ab12..."   # hex, 64 chars
    signed_at: "2026-04-17T12:34:56Z"   # ISO-8601 UTC
    manifest_version: 3            # monotonically increases (§3.4)
```

`signature` is **self-declarative metadata**, not the signature itself.
It tells a verifier *what* to verify and *which* public key to use.
The actual signature bytes are in the detached `.sig` file.

### 3.4 `manifest_version` — monotonic counter

A non-negative integer. MUST increase with every re-publish of the
same RRN. The registry refuses uploads where `manifest_version` does
not exceed the currently stored version. This prevents a downgrade
attack where an attacker re-publishes a stale signed manifest to hide
a newer one.

---

## 4. Algorithm choice

### 4.1 Default: Ed25519

**Decision:** v0.2 ships with Ed25519 as the sole required algorithm.

Rationale:

- Universally available: libsodium, NaCl, Python's `cryptography`,
  Node's `crypto.sign`, Go's `crypto/ed25519`, OpenSSL 1.1.1+.
- Small keys (32 bytes) and signatures (64 bytes) fit the "one small
  file" ethos.
- Deterministic signing — no RNG-failure foot-guns.
- No curve-choice bikeshed.

### 4.2 Optional: pqc-hybrid-v1

**Decision:** the `algorithm` enum accepts `pqc-hybrid-v1` as a second
value from day one, but the v0.2 CLI does **not** implement it. This
reserves the format slot so that when ML-DSA-65 tooling is stable we
can add it without a breaking change to the frontmatter.

The hybrid format, when implemented, will be:

```
pqc-hybrid-v1 = ed25519_signature || ml_dsa_65_signature
```

Verifiers MUST verify both components and MUST reject if either fails.
This is a belt-and-suspenders posture for the transition era.

**Decision required — PQ choice.** We are pre-committing to ML-DSA-65
(FIPS 204) rather than Falcon or SLH-DSA. User to confirm or push
back before we freeze the enum value name.

### 4.3 Algorithm tag is load-bearing

The `algorithm` field appears both in the frontmatter metadata and in
the detached signature envelope (§5). The two MUST match; verifiers
MUST reject if they differ. This prevents a substitution attack where
an attacker swaps the envelope for one using a weaker algorithm while
leaving the frontmatter claiming a stronger one.

---

## 5. Detached signature file format

`bob.ROBOT.md.sig` is a small JSON file (not base64'd raw signature —
we need room for algorithm tag, timestamp, and future multi-algorithm
envelopes):

```json
{
  "v": 1,
  "algorithm": "ed25519",
  "key_fingerprint": "sha256:ab12...",
  "signed_at": "2026-04-17T12:34:56Z",
  "manifest_sha256": "cd34...",
  "signature": "base64(sig_bytes)"
}
```

`manifest_sha256` is redundant with the signature (Ed25519 internally
hashes) but lets a consumer cheaply check manifest identity before
paying the verification cost. It is NOT a substitute for signature
verification.

`v: 1` is the envelope version, separate from the ROBOT.md spec
version. It lets us evolve the envelope (add fields, add algorithms)
without touching the ROBOT.md frontmatter format.

---

## 6. Tamper-evidence — the blockchain question

User asked us to "consider using some blockchain technology" and
clarified: **the goal is ease of use and cheapness for everyone.**
That constraint decides the answer. We evaluated four postures; only
the simplest one ships in v0.2.

| Option | Role | v0.2 decision |
|---|---|---|
| A. RRF D1 only (centralized) | Baseline storage for RRN→pubkey + manifest bytes | **Ships as v0.2** |
| B. Merkle transparency log | RRN→pubkey binding audit trail | Deferred to v0.3+ (optional upgrade, no format break) |
| C. IPFS / content-addressed storage | Manifest distribution | Deferred — opt-in future, not on-by-default |
| D. Public blockchain (L1/L2) | Anchor RRN→pubkey bindings | Rejected |

### 6.1 Option A — centralized D1 (what actually ships)

D1 stores RRN → current-pubkey bindings. D1 stores the signed
manifest bytes. That is the whole storage story for v0.2.

Why this is the right call given the ease/cheap constraint:

- **Zero extra cost to adopters.** No gas. No external accounts. No
  node to run. Registering a robot is one `POST` to rcan.dev.
- **Zero extra cost to RRF.** D1 is already deployed; adding two
  columns and a table is operations we already do.
- **Zero new mental model for users.** A user signs their ROBOT.md
  locally and uploads it. The registry verifies with the key it
  already bound at mint time. That is the entire flow.
- **Ed25519 already gives the main property** we actually need:
  anyone who downloads a signed ROBOT.md can verify the owner signed
  it, without trusting the registry at all. The registry is just a
  convenient host.

The trust assumption is "RRF will not silently swap a bound key." For
every robot operator at v0.2 scale, this is an acceptable floor — it
is already the trust floor of the entire RRN namespace, identical to
the trust floor of any domain-registrar-issued certificate.

**Limitation we accept:** a compromised RRF could rebind a key and
the rebind would not be externally provable. We document this
honestly in SECURITY.md and note it is the upgrade path for v0.3
(see §6.2).

### 6.2 Option B — Merkle transparency log (noted, not built)

A certificate-transparency-style append-only log of RRN→pubkey
binding events would give consumers "proof of no-swap" against a
compromised registry. The architecture is the one Google uses behind
HTTPS certificates today. It is *not* a blockchain: one writer, no
tokens, no consensus, just a signed Merkle tree served over HTTP.

Why we defer it to v0.3+ anyway:

- It is more code, more operational surface, and more concepts for
  users to understand. "What's an inclusion proof" is not a question
  a robot hobbyist should have to answer in their first hour.
- It adds zero value for users who aren't worried about a compromised
  RRF. For a v0.2-era ecosystem where the threat model is "random
  attacker on the internet," Ed25519 + centralized key binding is
  already strictly stronger than the status quo (unsigned manifests
  served from operator webhosts).
- It can be added later **without a breaking change to ROBOT.md or
  the CLI.** The frontmatter already carries enough metadata; the
  transparency log is a purely server-side + verifier-side addition.
  We lose nothing by waiting.

If and when the log lands, the CLI gains an optional
`robot-md verify --transparency` flag and users who care get stronger
evidence. Users who don't care see no change.

### 6.3 Option C — IPFS (deferred)

Attractive in theory for content-addressed mirroring, but every IPFS
integration we've seen in practice adds a "why is this failing to
pin" support-load on the maintainer. Users who want IPFS can pin the
signed manifest themselves — the signature works identically whether
the file is served from rcan.dev or from an IPFS gateway. We do not
need to take on gateway operations in v0.2.

### 6.4 Option D — public blockchain (rejected)

Directly violates the ease/cheap constraint. Rejected.

1. **Cost.** Per-binding on-chain writes cost real money (gas on L1,
   bridge costs on L2). That cost falls on either the user or the
   RRF, and we don't have a subsidy stream. Not cheap for anyone.
2. **Latency.** Finality measured in minutes (L1) or seconds under
   chain-specific assumptions (L2). Our use case does not need
   consensus; it needs "the owner signed this."
3. **Governance coupling.** Ties RRF's trust posture to a specific
   chain's operator set, token economics, and regulatory exposure.
4. **Operational surface.** Wallet custody, gas management, chain
   reorgs — each is its own production-hardening project.
5. **Solves the wrong problem.** We do not have a double-spend
   problem. Ed25519 + a centralized registry solves our actual
   problem (owner-authenticated manifests) with none of the above
   costs.

We remain open to anchoring *the root hash of a future Merkle log*
into a public chain as a cheap, infrequent belt-and-suspenders move
later. That's a once-a-day transaction, not a once-per-binding one,
and it's entirely optional. v0.3+ material at earliest.

---

## 7. Key lifecycle

### 7.1 Keygen

`robot-md keygen` generates an Ed25519 keypair and writes:

- `~/.robot-md/keys/<fingerprint>.priv` (mode 0600, owner-only read)
- `~/.robot-md/keys/<fingerprint>.pub` (mode 0644)

`~/.robot-md/` is created with mode 0700 if it does not exist. If it
already exists with wider permissions, the CLI **refuses to write**
and prints a fix-up command. No silently-broadening-permissions.

**Decision required — passphrase.** We propose making passphrase
protection opt-in via `--encrypt`. Default is unencrypted on disk
(relying on filesystem perms). Rationale: the single-robot developer
path should be frictionless; passphrase keys imply an agent or
GUI prompt and that is out of scope for v0.2. Users who want
passphrase protection opt in explicitly. User to confirm.

### 7.2 Binding at RRN mint

`robot-md register` uploads the public key **as part of the mint
request**, not after. The RRF issues an RRN only if the request
includes a public key; the binding is final at issuance.

Benefit: there is no window where an RRN exists without a key and
could be TOFU'd by the first uploader.

### 7.3 Rotation

A key rotation is a signed statement by the *old* key saying "the new
key for this RRN is K_new." The RRF verifies the statement against
the currently-bound key, then updates the binding in D1. Rotation
events are timestamped and retained in a `key_history` column so
historical verification (was K_old bound at time T?) remains
possible from registry-queryable state.

### 7.4 Revocation / compromise recovery

If the old key is lost or compromised and the owner cannot sign a
rotation, the RRF provides an out-of-band recovery flow (human
review, proof of RRN ownership via account auth, etc.). Recovery is
logged with a registry-signed note distinguishable from a
user-signed rotation.

**Decision required — recovery policy.** What counts as "proof of RRN
ownership" for recovery? Proposal: the same auth identity that minted
the RRN, plus a cooling-off period (e.g., 7 days) before the rebind
takes effect, during which the old key can still veto. User to
confirm.

---

## 8. CLI surface

New verbs shipped in CLI 0.2.0:

```
robot-md keygen [--encrypt] [--out DIR]
robot-md sign PATH [--key FINGERPRINT]
robot-md verify PATH [--against-rrn RRN]
robot-md register PATH [--ipfs-pin]
robot-md rotate --rrn RRN --new-key FINGERPRINT
```

Existing verbs (`validate`, `render`, `context`) are unchanged.

**`sign`** writes `PATH.sig` and adds the `metadata.signature` block
to the manifest. It refuses to run if the manifest already has a
different `key_fingerprint` than the key being used, unless
`--force-rebind` is passed (which is separately logged).

**`verify`** checks the detached signature against the public key
indicated by the frontmatter fingerprint. With `--against-rrn`, it
additionally fetches the current bound key from rcan.dev and confirms
it matches.

**`register`** is the REST client for §9.

---

## 9. Registry API changes

### 9.1 `POST /api/v1/robots` (mint)

Extend the mint request body with:

```json
{
  "metadata": { ... existing ... },
  "public_key": {
    "algorithm": "ed25519",
    "key_material": "base64(32 bytes)",
    "fingerprint": "sha256:ab12..."
  }
}
```

RRF verifies the fingerprint matches the key material, then atomically
(1) issues the RRN and (2) inserts the binding into D1.

### 9.2 `PUT /api/v1/robots/:rrn/manifest`

(Placeholder endpoint already scaffolded in rcan-spec at
`functions/api/v1/robots/[rrn]/manifest.ts` returning 501 in v0.1.1.)

Request body: raw ROBOT.md bytes.
Required headers:

```
Content-Type: text/markdown
X-Manifest-Signature: base64(envelope)        # the .sig file, base64'd
X-Manifest-Key-Fingerprint: sha256:<hex>
Authorization: Bearer <rrn-owner-token>
```

Server validates:

1. Caller owns the RRN (Authorization token).
2. `X-Manifest-Key-Fingerprint` matches the currently bound key.
3. Signature in `X-Manifest-Signature` verifies against the stored
   public key over the raw body bytes.
4. Frontmatter parses; `metadata.signature.key_fingerprint` equals
   `X-Manifest-Key-Fingerprint`; `metadata.signature.algorithm` equals
   the envelope algorithm.
5. `manifest_version` in frontmatter > currently stored
   `manifest_version`.

Responses:

- `201 Created` on first upload
- `200 OK` on update
- `401 Unauthorized` on auth failure
- `403 Forbidden` on key mismatch
- `409 Conflict` on non-monotonic manifest_version
- `422 Unprocessable Entity` on schema / signature failure

### 9.3 `GET /api/v1/robots/:rrn/manifest`

Returns the raw manifest bytes as `text/markdown` with:

```
X-Manifest-Signature: base64(envelope)
X-Manifest-Key-Fingerprint: sha256:<hex>
Access-Control-Allow-Origin: *
Cache-Control: public, max-age=300
```

Third-party validators can verify without any registry-trusted state:
they pull the signature, pull the current bound key (§9.4), and
verify locally.

### 9.4 `GET /api/v1/robots/:rrn/key`

Returns the currently bound public key:

```json
{
  "rrn": "RRN-000000000001",
  "algorithm": "ed25519",
  "key_material": "base64(32 bytes)",
  "fingerprint": "sha256:ab12...",
  "bound_at": "2026-04-17T12:00:00Z"
}
```

Transparency-log fields may be added in v0.3+ without breaking v0.2
clients — unknown fields are permitted.

---

## 10. D1 schema additions

```sql
-- key material bound to RRNs (current binding)
CREATE TABLE robot_keys (
  rrn TEXT PRIMARY KEY,
  algorithm TEXT NOT NULL,
  key_material BLOB NOT NULL,
  fingerprint TEXT NOT NULL,
  bound_at TEXT NOT NULL,
  rotated_at TEXT,
  FOREIGN KEY (rrn) REFERENCES robots(rrn)
);

-- historical binding events (rotation audit trail)
CREATE TABLE robot_key_history (
  seq INTEGER PRIMARY KEY AUTOINCREMENT,
  rrn TEXT NOT NULL,
  event_type TEXT NOT NULL,           -- 'BIND' | 'ROTATE' | 'REVOKE'
  algorithm TEXT NOT NULL,
  key_material BLOB NOT NULL,
  fingerprint TEXT NOT NULL,
  signed_by_fingerprint TEXT,         -- old key for ROTATE, NULL for BIND, 'RRF' for admin REVOKE
  recorded_at TEXT NOT NULL,
  FOREIGN KEY (rrn) REFERENCES robots(rrn)
);

-- signed manifest bodies
CREATE TABLE robot_manifests (
  rrn TEXT PRIMARY KEY,
  body BLOB NOT NULL,
  signature_envelope TEXT NOT NULL,   -- JSON string
  key_fingerprint TEXT NOT NULL,
  manifest_version INTEGER NOT NULL,
  uploaded_at TEXT NOT NULL,
  FOREIGN KEY (rrn) REFERENCES robots(rrn)
);
```

Note: D1 `exec()` does not work for DDL; use `prepare().run()` per
CLAUDE.md memory.

---

## 11. Things that need verification before we write the code

These are the items where "sounds right on paper" is not enough and we
must prove the capability exists before committing to the design.

1. **Ed25519 in Cloudflare Pages Functions (Workers runtime).** The
   Workers `SubtleCrypto` exposes Ed25519 as a supported curve for
   `importKey` / `verify` per 2023 runtime docs, but behavior in Pages
   Functions specifically should be smoke-tested with a real signature
   before we commit the verify path there. If unavailable, we fall
   back to a WASM library (noble-ed25519 has a Workers-compatible
   build).
2. **D1 BLOB column size.** A signed ROBOT.md plus envelope can easily
   be 10–50 KB. D1's per-row limit is 1 MB but we should confirm real
   insert/select performance at the expected size.
3. **Fingerprint canonicalization.** `sha256` of what exactly? We
   propose: `sha256(algorithm_bytes || 0x00 || key_material_bytes)` so
   that different-algorithm keys with numerically equal material
   cannot collide. User to confirm this is not overkill.

---

## 12. Rollout plan (design-level, not a commit plan)

1. Land this design doc. Get user sign-off on the Decision Required
   items.
2. Implement and smoke-test Ed25519 sign+verify in CLI in isolation
   (offline, no registry).
3. Add the Cloudflare-side verification path; prove it against known
   test vectors.
4. Implement the D1 schema and the `/key`, `/manifest`,
   `/transparency-log/sth` endpoints.
5. Wire `robot-md register` end-to-end.
6. Migrate Bob as the first signed robot, dogfood.
7. Tag `cli 0.2.0` and `spec v0.2`.

Each of steps 2–5 is an independent PR with its own code review. No
step ships without tests proving the cryptographic invariants.

---

## 13. Decisions required from user before implementation

Collected in one place for ease of review:

1. **§4.2** — PQ algorithm: pre-commit to ML-DSA-65? Or defer the
   enum-value name?
2. **§7.1** — Keygen passphrase default: accept "unencrypted on disk
   behind 0600" as the v0.2 default, with `--encrypt` opt-in?
3. **§7.4** — Recovery policy: accept "same auth identity + 7-day
   cooling-off" for lost-key recovery?
4. **§11.3** — Fingerprint canonicalization: accept
   `sha256(algorithm_bytes || 0x00 || key_material_bytes)`?

Blockchain-tech evaluation (§6) resolved per user feedback: ship
centralized D1 baseline, defer transparency log / IPFS / L1-anchoring
to v0.3+. No further decision needed on that axis.

---

## 14. What this design does not attempt

For clarity about what is **out of scope** for v0.2:

- No multi-signature / threshold schemes (one key per RRN).
- No hardware-security-module integration (TPM, YubiKey). A future
  CLI plugin point could add this; not v0.2.
- No fleet-level parent/child key derivation.
- No manifest encryption at rest. Manifests are public by design.
- No support for RRN transfer between owners. Out of scope; likely
  requires a signed transfer ceremony modeled on rotation.

---

## 15. Status

**Implementation pending user review of these decisions — no crypto
code will ship until you sign off.**

When you are ready, mark the five Decision Required items in §13 as
accepted (or propose edits), and the implementation plan of §12
begins at step 2.
