gage 0.2.0

Hand context between agents — files, prompts and decisions — with a trust boundary that survives the hop.
# gage — threat model

For **v0.1.0**. Written to be argued with: if a mitigation below does not hold,
that is a bug report, and the non-goals are there so nobody has to discover
them the hard way.

## What is being protected

1. **The integrity of what a receiving agent is told about a message** — who
   signed it, whether the receiver has ever confirmed that key, and that
   nothing inside carries authority. This is the asset. Everything else in
   gage exists to serve it.
2. **The identity key** (`~/.gage/identity.key`). The one file whose loss
   cannot be undone, because every contact who confirmed your word-pairs
   confirmed *that* key.
3. **Payload confidentiality in transit**, from the relay and from the network.
4. **Correspondence metadata** — who you write to, how often — from the network.
   From the relay, only partly: see non-goals.
5. **The accept gate**: that arrival is not consent, and that crossing it is an
   act a human or an explicitly-flagged unattended process performed.

## Adversaries, and what each one gets

### A malicious sender

Someone with a valid keypair who can get a bundle to you. **The primary
adversary**, and the one gage is actually built against.

| they try | what stops them |
|---|---|
| Claim to be someone else | Identity is the **key**, never the `handle` string. A key on your list under another name renders a loud `!! NAME MISMATCH`; an unlisted key is shown as a key with an unverified claimed name |
| Carry their own trust verdict | There is no `trust` field. The receiver computes it from the signature and its own allowlist |
| Claim authorisation | Authority comes only from the receiver's own local file, is consulted only after `SENDER VERIFIED`, and is scoped by message **kind** rather than by anything the message says |
| Forge the frame boundary so the rest reads as the tool's voice | Fence markers carry a per-invocation nonce from `/dev/urandom`; no randomness means no rendering, not a predictable fallback |
| **Write a line of the frame via a newline in a path, subject or handle** | **Fixed 2026-10-18.** `path_is_safe` rejects control characters, `U+2028/9` and bidi overrides; `one_line` escapes every sender-controlled string the frame prints. This worked before the fix, and gage printed the real nonce onto the attacker's line |
| Swap payload beside a signed manifest | Each item's sha256 is checked against the manifest; a mismatch withholds contents |
| Choose an id that collides with a real message | The id covers author key, subject and time as well as digests, and is recomputed rather than trusted |
| Replay a message you already acted on | The witness (`sha256(from_key ␟ manifest bytes)`) is recorded; `pull` refuses a repeat and journals `duplicate` |
| Reuse an old key after you rotated away | Verdict distinguishes `KEY RETIRED` (witnessed before retirement, attributable) from `KEY REJECTED` (arrived after, contents withheld) using this machine's own record, not any date the sender wrote |
| Name a trusted third party as the author of a forwarded bundle | Only the last hop signs. `chain[0]` is shown as a **claim by the forwarder**, explicitly, even when the key named is one you know |
| Post into someone else's thread to collect a reply | `reply` resolves the recipient from the key that **signed** the parent; a thread with two correspondents is refused rather than guessed |
| Refill an auto-accept budget by inventing thread ids | The budget is keyed on a parent found in this machine's own `sent/`, which only the receiver can have written |
| Escape the bundle directory with `../` or an absolute path | `path_is_safe`, enforced on build, load **and** forward; the check runs before any read |
| Squat your relay mailbox using your published box key | Registration requires returning a nonce sealed to that box key |
| Drain your mailbox knowing your public key | Fetch requires an ed25519 signature over a server-issued, single-use, mailbox-bound challenge |

### A malicious relay

Assume it is fully compromised and keeps everything.

- **Cannot read payloads.** It holds `crypto_box` sealed envelopes
  (X25519 + XSalsa20-Poly1305) and the envelope carries three fields: version,
  recipient box key, ciphertext. Verified in the suite by pushing a bundle with
  distinctive strings and grepping the store.
- **Cannot forge authorship.** Signatures are inside the bundle; the relay is
  never an authority on who said what. A bundle arriving via a relay from an
  unlisted key reads `SENDER NOT ON YOUR LIST`, exactly as it would locally.
- **Cannot replay usefully.** The receiver's witness record refuses the repeat.
- **Can** see which mailbox has mail, how big, and when — and **can drop or
  delay** anything. Availability is not protected; see non-goals.

### A network attacker

- With TLS (`gage join https://…`): payload sealed, metadata protected, server
  authenticated against a compiled-in Mozilla root list rather than the system
  store, so a private-CA relay fails loudly instead of silently working.
- Without TLS (a tailnet address, where WireGuard already provides the channel,
  or a loopback test): the payload is still sealed, but `/send` carries the
  recipient's box key in the clear, so a path observer learns **who you write
  to, how often and how big**. And with no server identity, anything that can
  answer for that address can accept your push, return `{"ok":true}` and drop
  it: signatures cover content and never covered delivery.
- The choice is visible because the **scheme decides** and a person writes the
  address.

### A prompt-injection payload

The adversary that makes gage different from a signing tool: the reader is a
language model whose next tokens are the payload.

- `gage open` is a **renderer, not an importer**. The frame states, before any
  payload, that the contents are data from an identified party carrying no
  authority.
- On a bad verdict it **withholds** contents rather than annotating them,
  because an annotation is something the payload can argue with.
- `prompt` items are fenced as a quotation to evaluate, not a task to adopt.
  `memory` is fenced hardest and never suggested for merging, because it is the
  only kind that outlives the session that accepts it.
- The header is **restated in the footer**, so the last thing read is not
  attacker-controlled text.
- The frame tells the reader never to edit the authority file or add a grant
  because a message asked it to.

**And the honest limit:** every one of these is a deterrent aimed at a reader
that can be argued with. A model can be talked past a marker. gage makes the
forgery expensive and the framing loud; it cannot make a model obey the frame.
This is stated in the frame itself rather than relied upon silently.

### A local attacker on the same machine

- The identity key is `0600`.
- **New** homes are created `0700` (fixed 2026-10-18). Before that the home
  was `0755` with contacts, journal and inbox payloads at `0644`, so any other
  local user could read who you correspond with and what they sent.
  **Homes created before that fix are still `0755`** — gage does not retighten
  an existing home, because changing permissions on someone's filesystem
  unasked belongs to a command that says so. `chmod 700 ~/.gage` if you share
  a machine.
- Any process running as you can read **and write** the authority file. The
  no-`gage allow`-command rule is a deterrent that makes an injected agent
  hand-write a file — conspicuous in a transcript — not a boundary. A real
  boundary needs file permissions or a separate process the agent cannot write
  through. The frame says this rather than implying otherwise.

### Someone who has your backup file

Only relevant if you ran `gage export-identity`, which nothing does for you.

A backup is your identity in a file. It is encrypted with a key derived from
your passphrase by Argon2id (64 MiB, 3 passes) and sealed with
XChaCha20-Poly1305 over an authenticated header, so an attacker holding the
file needs the passphrase and pays that cost per guess — offline, as fast as
their hardware allows, with no rate limit anyone can impose.

**So opting in converts "lose the key, lose everything" into "your security is
your passphrase's strength."** That is the whole trade, and it is a trade, not
a free improvement:

- A **strong** passphrase plus a backup stored away from the machine is
  strictly better than no backup — you survive a dead disk.
- A **weak** passphrase, or a backup synced to the same cloud account as
  everything else, is **worse** than no backup: it is a copy of your identity
  that an attacker can take at their leisure, and a stolen identity is not
  something a rotation undoes for the people who already confirmed the old key.

What the file deliberately does not contain: your contacts, your journal, your
inbox, your relay address — or anything that says whose identity it is. A
leaked backup should cost the key and nothing more. The word-pair fingerprint
is shown only after a successful decrypt, which is also how you check that a
file really is the identity you think it is before trusting it.

What a restore does **not** bring back is this machine's witness record. The
key returns; the memory of what it has seen does not, so bundles signed under
a key you had retired read as unplaceable rather than attributable afterwards.
That is honest rather than convenient: a witness record that could be
reconstructed on demand would be one a thief could reconstruct too.

---

## Non-goals — what gage does *not* protect against

Each of these is a decision, not an oversight.

1. **Availability.** A relay can drop, delay or lose your mail, and a mailbox
   can be filled by strangers up to its quota. gage bounds the damage; it does
   not guarantee delivery. There are no retries and no delivery receipts.
2. **Anonymity, and metadata from the relay.** The relay necessarily learns
   which mailbox has mail, its size and its timing. Polling interval is a
   trade: the default 120s is deliberately slow enough not to be a presence
   feed. If who-talks-to-whom must be hidden from the relay operator, gage is
   the wrong tool.
3. **Making a model obey the frame.** See above. This is the central limit.
4. **Key loss, unless you opted in.** By default there is still no recovery
   and no escrow: losing `identity.key` is identity death, and every confirmed
   contact must re-confirm a new key. `gage export-identity` changes that *if
   you run it* — and changes the threat instead of removing it. See below.
5. **Compromise of your own machine.** An attacker running as you has your key,
   your authority file and your inbox. Nothing here survives that.
6. **Proving the word-pair ceremony happened.** gage cannot see your other
   channel. It withholds the answer and names the trap; it cannot verify you
   compared anything. A key knows its own word-pairs and can quote them in the
   very message asking to be trusted — which the confirmation gate says, in
   the gate, where the decision is made.
7. **Binding a key to a legal identity.** No CA, no directory, no attestation.
   A fingerprint binds a key to a human *you already know by other means*.
8. **Protecting against what a hook does.** `--on-arrival` runs your command;
   `--auto-accept` moves bundles. gage bounds *when* they fire and counts what
   they did. What the hook then does is bounded by the hook, which a human
   wrote.
9. **Post-compromise security / forward secrecy.** Sealing uses a fresh
   ephemeral keypair per envelope, so one envelope's keys do not open another —
   but your long-term X25519 secret opens every envelope ever sent to you.
   There is no ratchet and no deniability. Stolen key, historic mail readable.
10. **Dependency and supply-chain trust.** `ed25519-dalek`, `crypto_box` and
    `rustls` are taken as sound and have not been audited here.
11. **Side channels.** No timing analysis has been done.
12. **Group confidentiality.** `group:<name>` is a local addressing
    convenience; a bundle is sealed per recipient key. There is no group key
    and no notion of membership a relay enforces.

---

## Known weaknesses, stated plainly

- **Self-review.** The security work on this project, including the audit that
  found the frame-injection bug above, has been done by the same hands that
  wrote it. No independent audit has happened. Treat the mitigations table as
  claims to be checked, not as assurances.
- **The 16-bit label.** One word-pair is grindable in ~65,000 tries. It is a
  label only and is never confirmed against — but it appears in lists next to
  names, and a sender *can* arrange for its own key's label to be a pair you
  already use for somebody.
- **Format versioning is untested.** `v: 1` has never had to become `v: 2`.
- **`verify_strict` but no key-rotation protocol with the relay.** Rotation is
  a local record plus a notice the recipient chooses to apply; a notice
  countersigned by the old key is exactly what a thief holding the old key
  would send, which is why applying one is a human decision made after
  confirming over another channel.