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
8 changes: 8 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,11 @@ baa7a0bb4b5f4c3112680c37ab7572663bb87551:apps/desktop/vite.webclient.config.ts:g
# that introduced the literal still carries it in its own patch, and gitleaks
# fingerprints are commit-scoped. Scoped to that one commit and finding.
359ae24fd3fdf638b1e4b28cbeea34c068285aad:apps/ade-cli/src/services/diagnostics/diagnosticReport.test.ts:generic-api-key:54

# cli.test.ts's formatDiagnosticError redaction tests need secret-shaped
# fixtures to prove they are stripped. The working tree assembles them from
# segments at runtime, but the commit that introduced the literals still
# carries them in its own patch, and gitleaks fingerprints are commit-scoped.
# Scoped to that one commit and its two findings.
7ec89db49bbd5f9724915222008be6fc0b264682:apps/ade-cli/src/cli.test.ts:generic-api-key:11792
7ec89db49bbd5f9724915222008be6fc0b264682:apps/ade-cli/src/cli.test.ts:generic-api-key:11808
187 changes: 181 additions & 6 deletions apps/account-directory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,10 @@ nothing at all. Two proofs are accepted, either one sufficient:
2. **A pairing grant (fallback).** 32 random bytes minted at `POST /device/token`
— the one interactive sign-in this Worker runs end to end — stored as a
SHA-256 digest in `machine_pairing_grants`, bound to the signing-in user and
to the `machine_key` declared back at `POST /device/code`, valid for
`PAIRING_GRANT_TTL_MS` (10 minutes), and redeemed by a single conditional
`DELETE` so it is spendable exactly once. A removed machine holding only an
old access token cannot obtain one: minting requires completing the browser
half of the device flow.
to the `machine_key` declared back at `POST /device/code`, and valid for
`PAIRING_GRANT_TTL_MS` (10 minutes). A removed machine holding only an old
access token cannot obtain one: minting requires completing the browser half
of the device flow.

The fallback exists because path 1 fails closed and ADE's brain authenticates
with a Clerk **OAuth access token**, whose documented claim set does not include
Expand All @@ -75,11 +74,161 @@ actionable message rather than a bare status. An accepted re-pair clears the
relay's revocation first, so a machine is never back on the roster while still
unable to publish.

### Spending a grant takes two phases

A grant is spendable exactly once, but a spend is not a single `DELETE`. The
relay hand-off that follows can fail, and destroying the grant before knowing
the outcome meant a relay outage burned the only credential a reinstalled
machine had — the same lockout the grant exists to prevent, moved one step
later. So redemption is:

1. **Reserve.** One atomic `UPDATE ... SET reserved_at` whose `WHERE` still
carries every rule (this user, this machine, inside its TTL, not already
held). `changes === 1` is the whole proof, so two concurrent registrations
can no more both spend it than they could before.
2. **Consume** (`DELETE`, scoped to that reservation) once the relay agrees, or
**release** (`SET reserved_at = null`) when it does not.

A release restores the row exactly as it was. `expires_at` is never rewritten,
so an attacker who can force relay failures gains nothing beyond the TTL the
grant was minted with. A reservation older than `PAIRING_GRANT_RESERVATION_MS`
(60 s) is ignored, so a Worker that dies mid-hand-off strands the grant for a
minute rather than until it expires.

## Superseding a rotated machine key

Machines are keyed `(user_id, machine_key)`, so a client that rotates its
identity file — a reinstall, a wiped config directory, a restored backup —
arrives as a **second row for one physical computer**. The user then removes the
row that looks stale, and half the time that is the live install.

A register call whose `deviceId` **or** `hardwareId` matches other rows on the
same account therefore deletes those rows and reports them:

```json
{ "machineKey": "...", "supersededMachineKeys": ["<older key>"] }
```

The field is additive and omitted when nothing was superseded, so existing
clients are unaffected. Three rules bound it:

- **Two identifiers, one union.** `deviceId` catches an in-place reinstall,
where `~/.ade/secrets` survived. `hardwareId` — an optional, per-account
sha256 of an OS-level machine identifier (`IOPlatformUUID`, `MachineGuid`,
`/etc/machine-id`) — catches a full `~/.ade` wipe, where the device id was
minted fresh alongside the machine key and matches nothing. It is salted with
the account id, so one machine seen by two accounts stores two unrelated
values and the column cannot correlate users. Rows with a null `hardware_id`
(written before it shipped, or by a host that cannot read one) are matched by
`deviceId` only, and nothing back-fills them.
- **Same trust bar as a re-pair.** `deviceId` and `hardwareId` are both
caller-supplied and forgeable, so on a plain token they authorize nothing —
otherwise any machine could claim another's identifiers and delete its row.
The call must carry proven-fresh interactive authentication or spend a pairing
grant, exactly as un-revoking does. A grant is only spendable on
`pairing: true`; the claim is honored on any register, because it is a
property of a token this Worker verified.
- **At most 5 rows per call** across both identifiers, oldest-seen first. The
rest go on the next proven re-pair.

It **folds**, it does not merely delete: the one thing a superseded row holds
that the new one cannot rebuild is `custom_name`, the name the user typed. The
most recently seen superseded name is carried onto the surviving row, and only
when that row has no name of its own — a name set on the new row is the fresher
statement of intent. The carry-forward and the deletes go out as a single
`DB.batch()`, because the pairing grant is already spent by the time they run
and a half-finished loop would leave phantoms behind with no credential left to
clear them.

Superseded keys get **no** `revoked_machines` row. The physical device holds the
new key, and blocking the old one would trapdoor any client that rolls its
identity file back into a permanent refusal; an absent key simply registers
again. The relay is not called either — the device never left the account, so
its Activity is still the user's own.

## Refusal logs

Every refusal on this Worker is a user who cannot get their computer back onto
their account, and by the time they ask for help the request is gone. Each
refusal path emits exactly one structured line to `console.log` (Workers
observability runs at `head_sampling_rate: 1`):

```json
{"event":"directory.register_refused","userId":"user_…","machineKeyPrefix":"abcdef12",
"deviceIdPrefix":"01234567","code":"machine_revoked","correlationId":"…"}
```

`event` is one of `directory.register_refused`, `directory.remove_refused`, or
`directory.supersede_refused`; `code` is the wire code the client received
(`machine_revoked`, `pairing_authentication_required`,
`activity_relay_unavailable`, `activity_purge_failed`,
`supersede_authentication_required`), and an optional `reason` carries the finer
classification support actually needs — `no_proof` versus `grant_rejected`, or
the relay's own failure text. `correlationId` joins the line to the request the
client logged.

Identifiers appear as **8-character prefixes only**. A machine key is
capability-shaped and a grant is a live credential; no full key, token, or grant
is ever logged.

There is no admin route for restoring a machine by hand, and this change did not
add one: the Worker has no secret-gated inbound surface to extend
(`DIRECTORY_AUTH_SECRET` is outbound provenance for the relay, not an inbound
credential), and adding one would be a new authentication boundary guarding
exactly the tables `wrangler d1 execute --env production` already reaches.
Support recovery is a direct D1 statement — typically
`delete from revoked_machines where user_id = ? and machine_key = ?` — after the
refusal logs above identify the row.

Machine registration and list records may carry a `pubkey` string. Current ADE
hosts publish `ed25519:<raw-32-byte-base64>` so clients can verify and seal
account adoption on direct or relay routes. The Worker treats the value as
opaque metadata and rejects values longer than 128 characters.

## Diagnostic report uploads

`POST /diagnostics/upload` is the destination for ADE's "Send to ADE" button and
`ade report-issue --send`. It exists because support round-trips were the real
cost of a broken install: the report is already built and fully redacted on the
user's machine, and asking someone whose ADE will not start to run terminal
commands and paste output is where most of them stalled.

**Contract**

| | |
|---|---|
| Method | `POST` (plus `OPTIONS` preflight; anything else is `405`) |
| Body | `text/plain` — the report itself; or `application/json` — `{ report, installId?, appVersion? }` |
| Metadata on `text/plain` | `?installId=` / `?appVersion=` query parameters |
| Auth | **Optional** `Authorization: Bearer <Clerk token>`, verified exactly as the account routes verify it. Absent, the upload is anonymous. A header that is sent and does not verify — or does not even parse as `Bearer <token>` — is `401`, never silently downgraded. A Worker with no Clerk configuration answers `503`, exactly as the account routes do |
| Origin | `403` when the browser reports `sec-fetch-site: cross-site` from a real remote origin. ADE's own senders are unaffected: the CLI sends no fetch-metadata header, and the Electron renderer's `null` (packaged `file://`) and loopback (development) origins are exempt |
| Size | `413` above 512 KB. `content-length` is checked first, then the stream is counted as it arrives, so a missing or dishonest length changes nothing |
| Rate limit | 5 per UTC day per user (signed in) or per `cf-connecting-ip` (anonymous) → `429` with `retry-after: 86400`. Off Cloudflare there is no trustworthy address, so anonymous callers share one bucket; `x-forwarded-for` is caller-controlled and is never read |
| Success | `200 {"ok": true, "id": "<uuid>"}`. The report is **never** echoed back |
| Storage | `reports/<utc-date>/<userIdOrAnon>/<uuid>.md` in the `DIAGNOSTICS` R2 bucket, with `userId` / `installId` / `appVersion` as custom metadata |
| No binding | `503`, and the in-app button says sending is unavailable |

The key's identity segment is `u-<clerk user id>` when signed in and
`anon-<sha256(ip) prefix>` otherwise — the *same* segment the quota is counted
on, so one prefix listing answers both "where does this go" and "has this caller
had enough today".

CORS is `*` on this route only. The desktop button runs in Electron's renderer,
whose origin is `file://` (`Origin: null`) in a packaged build, so no fixed
allow-list can name it; `*` is safe here because the route reads no account
state, returns only an opaque id, and cannot be used with
`credentials: "include"`. Every `/account/*` route keeps its exact-origin rule.

**Rate limiting without a migration.** The device flow counts attempts in the
`device_approval_rate_limits` D1 table. This route deliberately does not: it
ships without touching `migrations/`, so the quota is enforced by a per-isolate
counter (fast, but lost when Cloudflare recycles the isolate) backed by an R2
prefix listing (durable and global, one class-A operation per upload). The
listing is not transactional, so genuinely simultaneous requests can land a
couple of objects over five. For a bound whose only job is "one person cannot
fill the bucket", that is an acceptable trade; if volume ever justifies exact
counting, move it to the D1 pattern the device flow already uses.

## Local checks

```sh
Expand Down Expand Up @@ -114,7 +263,33 @@ deployment:
`DIRECTORY_AUTH_SECRET` (`npx wrangler secret put DIRECTORY_AUTH_SECRET`) to
the same value configured on the push relay; machine removal and re-pairing
both fail loudly without it.
3. Apply the remote migrations and deploy the Worker. Use
3. Create the R2 bucket behind the `DIAGNOSTICS` binding, **before** the deploy
that first references it — `wrangler deploy` does not create buckets, and a
Worker bound to a bucket that does not exist fails to start:

```sh
npx wrangler r2 bucket create ade-diagnostics # default environment
npx wrangler r2 bucket create ade-diagnostics-production # production
```

The binding is optional in code, so an already-deployed Worker whose bucket
was removed answers `503` on `/diagnostics/upload` and keeps every other
route working.
4. Give both diagnostics buckets an expiry lifecycle rule. **Nothing in the
Worker ever deletes a report**, so without this the bucket grows forever and
every report a user ever sent stays readable indefinitely. Ninety days is the
default because it is far longer than any support thread and far shorter than
"forever" — shorten it if your retention policy says so:

```sh
npx wrangler r2 bucket lifecycle add ade-diagnostics \
expire-reports reports/ --expire-days 90
npx wrangler r2 bucket lifecycle add ade-diagnostics-production \
expire-reports reports/ --expire-days 90
```

Confirm with `npx wrangler r2 bucket lifecycle list <bucket>`.
5. Apply the remote migrations and deploy the Worker. Use
`npm run d1:migrate:production` and `npm run deploy:production` for the
production environment. Each deploy script validates only the environment it
is about to publish, so an unconfigured development Worker cannot block a
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
-- Two-phase redemption for pairing grants.
--
-- Redemption used to be a single DELETE: the grant was destroyed BEFORE the
-- activity relay was asked to lift the machine's publish block, and it was
-- deliberately not put back when that hand-off failed. The reasoning was sound
-- as far as it went — restoring a deleted grant means either a non-atomic
-- read-then-write (two concurrent registrations each see it unspent) or
-- re-issuing it with a fresh expiry (an attacker who can force relay failures
-- keeps one alive indefinitely) — but the cost landed on real users: a relay
-- outage during a re-pair burned the only credential a reinstalled machine had,
-- and the account token it still held could not mint another. That is the
-- lockout the revocation work was supposed to have removed.
--
-- `reserved_at` splits the spend in two so neither horn of that dilemma
-- applies. Phase one is an atomic UPDATE that claims the grant — same single
-- statement, same `changes === 1` proof, so concurrency is unchanged and no
-- second registration can hold it at the same time. Phase two either deletes
-- the row (relay agreed) or clears `reserved_at` back to null (relay failed).
--
-- The release restores the row EXACTLY as it was: `expires_at` is never
-- rewritten, so forcing relay failures buys an attacker nothing beyond the
-- original TTL the grant was minted with.
--
-- A null `reserved_at` means unheld. A reservation older than the worker's
-- crash-safety bound also counts as unheld, so a worker that dies mid-relay
-- strands the grant for at most that long rather than until it expires; the
-- bound lives in the worker (`PAIRING_GRANT_RESERVATION_MS`) because it is a
-- property of one relay round trip, not of the schema.
--
-- Additive and backfill-free: every existing row reads as unheld, which is what
-- an unspent grant already was.
alter table machine_pairing_grants add column reserved_at integer;

-- The register path already selects duplicate rows for one (user, device) pair
-- when a proven re-pair supersedes a rotated machine key. The existing index is
-- (user_id, last_seen_at), which does not serve that predicate.
create index if not exists idx_machines_user_device
on machines(user_id, device_id);
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
-- A machine identifier that survives a full `~/.ade` wipe.
--
-- Supersede-by-device dedup (0007's index, and the register path that uses it)
-- assumed the device id outlives a reinstall. It does not: `sync-device-id`
-- lives in `~/.ade/secrets` next to the machine key, so the user who deletes
-- `~/.ade` and signs in again mints BOTH halves fresh. There is then nothing to
-- match on, and the account keeps a row for a computer the user owns once.
--
-- `hardware_id` is the client's per-account hash of an OS-level machine
-- identifier — `IOPlatformUUID`, `MachineGuid`, `/etc/machine-id` — that no ADE
-- uninstall can remove. It is HASHED WITH THE ACCOUNT ID, so the same physical
-- machine registered under two accounts stores two unrelated values and this
-- column cannot be used to correlate users.
--
-- Nullable and never backfilled. Rows written before this shipped, and rows
-- from clients that cannot read an anchor, simply keep matching on device id —
-- which is still the common case for an in-place reinstall.
alter table machines add column hardware_id text;

-- Serves the second half of the supersede predicate. Same shape and reasoning
-- as `idx_machines_user_device`: the register path selects duplicate rows for
-- one (user, anchor) pair, and the (user_id, last_seen_at) index does not serve
-- that. Rows with a null anchor are still indexed by SQLite but are never
-- matched, because `hardware_id = null` is never true.
create index if not exists idx_machines_user_hardware
on machines(user_id, hardware_id);
Loading
Loading