···11+# atproto Record Signature Verification Plan
22+33+## Gap
44+55+`backend/electron/atproto-source.ts:356` fetches a `space.peek.feature.release`
66+record via `com.atproto.repo.getRecord` and accepts whatever the PDS returns.
77+No signature is checked. `verifyBlobCid()` (line 502) only confirms that the
88+downloaded bundle bytes hash to the CIDs **declared by that record** — if the
99+record itself was forged, the CIDs inside it can be anything.
1010+1111+A compromised PDS (or a malicious party who controls the PDS answering for a
1212+DID) can:
1313+1414+1. Serve a forged record with CIDs that point at tampered bundle blobs.
1515+2. Serve those tampered blobs when the client calls `com.atproto.sync.getBlob`.
1616+3. The client's CID check passes because the CIDs in the forged record match
1717+ the tampered bytes.
1818+1919+Today's trust boundary is TLS to the PDS, not the publisher's signing key.
2020+2121+## How atproto signatures actually work
2222+2323+Atproto does not sign records individually. It signs the **commit** at the
2424+top of the repo's Merkle Search Tree (MST). Record trust is transitive via
2525+MST inclusion.
2626+2727+### Commit object
2828+2929+```
3030+commit {
3131+ did, // the author's DID
3232+ version, // repo format version (currently 3)
3333+ data, // CID of the MST root
3434+ rev, // tid — monotonic revision id
3535+ prev, // CID of the previous commit (or null)
3636+ sig, // raw signature bytes
3737+}
3838+```
3939+4040+The signed message is `dag-cbor(commit-with-sig-field-removed)`. The signature
4141+is low-S ECDSA over SHA-256 of that encoding. Non-low-S signatures MUST be
4242+rejected.
4343+4444+### Signing key
4545+4646+- `did:plc` → `https://plc.directory/<did>` returns the DID document. The
4747+ `verificationMethod` entry with id `#atproto` carries the repo signing key
4848+ as `publicKeyMultibase`.
4949+- `did:web` → `https://<host>/.well-known/did.json`, same entry.
5050+- The multicodec prefix on the multibase-decoded key identifies the curve:
5151+ - `0xe7 0x01` → secp256k1
5252+ - `0x80 0x24` → P-256
5353+5454+### MST inclusion
5555+5656+Each MST node is dag-cbor. Leaves hold `{key, value: CID}` pairs. Re-hashing
5757+a node's encoding must produce the CID the parent node points at. Record
5858+trust = "commit is valid AND the record's CID appears at the correct leaf
5959+under commit.data."
6060+6161+## Verification steps the client must perform
6262+6363+1. **Resolve DID → DID doc.** Extract PDS endpoint AND the `#atproto`
6464+ signing key.
6565+2. **`com.atproto.sync.getLatestCommit(did)`** → `{cid, rev}`.
6666+3. **`com.atproto.sync.getBlocks([commitCid])`** → raw CBOR of the commit.
6767+4. **Verify commit signature.**
6868+ - Decode commit CBOR.
6969+ - Extract `sig` field.
7070+ - Re-encode the commit minus `sig` as dag-cbor → message.
7171+ - ECDSA-verify `(sha256(message), sig, publicKey)` using the curve
7272+ indicated by the multicodec prefix. Enforce low-S.
7373+ - If fail → abort install.
7474+5. **Walk MST from `commit.data`** to the leaf keyed by
7575+ `space.peek.feature.release/<rkey>`. At each node, fetch via `getBlocks`,
7676+ re-hash, confirm against the parent's CID. If any hash mismatches → abort.
7777+6. **Cross-check record CID.** The leaf's `value` CID must match the CID of
7878+ the record bytes returned by `getRecord` (or preferably: skip `getRecord`
7979+ entirely and fetch the record CBOR via `getBlocks` at the leaf's value
8080+ CID, then validate its schema).
8181+7. **Now `verifyBlobCid()` closes the chain.** Publisher's key → commit
8282+ signature → MST inclusion → record CID → blob CIDs → blob bytes.
8383+8484+## Implementation approach
8585+8686+### Library
8787+8888+`@atproto/crypto` (npm, maintained by Bluesky) gives us:
8989+- Parsing of `publicKeyMultibase` for both curves
9090+- Low-S ECDSA verification for secp256k1 and P-256
9191+9292+That's the smallest reasonable dependency. The MST walker is small enough to
9393+hand-roll against the spec if we want to keep the install path lean; otherwise
9494+`@atproto/repo` has one. Decision: **vendor `@atproto/crypto` only, hand-roll
9595+MST walk**, to keep the install path's dependency surface tight.
9696+9797+### File layout
9898+9999+- `backend/electron/atproto-verify.ts` — new module:
100100+ - `resolveSigningKey(did): {curve, publicKey}` — DID doc fetch + key
101101+ extraction.
102102+ - `fetchSignedCommit(pdsUrl, did): {commit, commitCid, sig, signedBytes}`.
103103+ - `verifyCommitSignature(signedBytes, sig, signingKey): boolean`.
104104+ - `verifyRecordInclusion(pdsUrl, commit, collection, rkey): recordCid`.
105105+ - `fetchAndVerifyRecord(atUri): verifiedRecord` — the public entry point
106106+ the installer calls instead of `getRecord`.
107107+- `backend/electron/atproto-source.ts` — replace the naive `getRecord` call
108108+ in `resolveInternal()` with `fetchAndVerifyRecord()`. Keep the existing
109109+ `verifyBlobCid()` unchanged; it remains the last link in the chain.
110110+111111+### Key rotation (did:plc)
112112+113113+A conservative client rejects a commit signed by a key that wasn't the key
114114+**active at the commit's `rev`** according to the PLC audit log (available
115115+at `https://plc.directory/<did>/log`). This prevents a stolen historical key
116116+from being used to forge new commits.
117117+118118+Phased rollout:
119119+120120+- **Phase 1**: verify against the *current* `#atproto` key from the DID doc.
121121+ This is what most atproto clients do today. Big step up from the status
122122+ quo; ships the feature.
123123+- **Phase 2**: walk the PLC audit log, find the key active at the commit's
124124+ `rev`, verify against *that*. Adds ~50 lines and one extra HTTP call but
125125+ removes the stolen-old-key attack.
126126+127127+### Icon / screenshot blobs
128128+129129+Currently unreferenced from the `space.peek.feature.release` record, so they
130130+are **not covered** by any CID or signature chain. A PDS can substitute them
131131+freely. Two options:
132132+133133+1. **Include them in the lexicon.** Add `icon: blob` and
134134+ `screenshots: blob[]` fields to `space.peek.feature.release`. `publish.js`
135135+ already uploads these as blobs; just wire their CIDs into the record.
136136+ Existing CID validation then covers them.
137137+2. **Document as cosmetic.** If we decide icons/screenshots are not
138138+ security-sensitive (they never execute), note that in the lexicon doc and
139139+ accept the cosmetic-substitution risk.
140140+141141+Recommend option 1 — cost is trivial once the signature chain exists.
142142+143143+### Caching
144144+145145+A verified record is safe to cache on disk keyed by `(did, collection, rkey,
146146+commitCid)`. Invalidate when `getLatestCommit` returns a newer `rev`. Saves
147147+repeated MST walks on update checks.
148148+149149+### Failure modes
150150+151151+Every verification failure MUST abort install and surface a clear error:
152152+153153+- `DID resolution failed` — network or malformed DID doc.
154154+- `Signing key not found` — DID doc has no `#atproto` verificationMethod.
155155+- `Unsupported key curve` — multicodec prefix outside {secp256k1, P-256}.
156156+- `Commit signature invalid` — the big one. Means the PDS is lying or the
157157+ publisher's key was compromised.
158158+- `MST inclusion failed` — commit is valid but doesn't contain the claimed
159159+ record; PDS is lying about which record exists.
160160+- `Record CID mismatch` — record bytes don't match the leaf's value CID.
161161+- `Blob CID mismatch` — already covered by existing `verifyBlobCid()`.
162162+163163+Each should log the DID, the atUri, and the reason before aborting. Do not
164164+fall back to unverified install under any circumstance.
165165+166166+## Testing
167167+168168+- **Unit tests** (`backend/electron/atproto-verify.test.ts`):
169169+ - Golden signed commit fixture → verifies cleanly.
170170+ - Flipped bit in commit body → rejects.
171171+ - Flipped bit in `sig` → rejects.
172172+ - Non-low-S signature → rejects.
173173+ - Wrong curve in DID doc vs signature → rejects with useful message.
174174+ - MST leaf at the wrong path → rejects.
175175+ - Tampered MST node hash → rejects.
176176+- **Integration test**: stand up a local PDS fixture (or record a real one),
177177+ happy-path install, then a fault-injection test that mutates one byte of
178178+ the bundle and asserts install aborts with `Blob CID mismatch`.
179179+- **Adversary test**: run the installer against a hand-crafted "malicious
180180+ PDS" fixture that returns a forged record with CIDs pointing at tampered
181181+ blobs the fixture also serves. Must abort at commit signature verification,
182182+ not after downloading anything significant.
183183+184184+## Scope and cost estimate
185185+186186+- Core signature + MST verification: ~200–300 lines.
187187+- `@atproto/crypto` dependency add: trivial.
188188+- Test fixtures (golden commit, malicious PDS): ~1 day.
189189+- Phase 1 (current key): ~2 days including tests.
190190+- Phase 2 (PLC log walking): ~0.5 day once Phase 1 ships.
191191+- Icon/screenshot lexicon extension: ~0.5 day.
192192+193193+Total: ~3 days for a complete tamper-evident install chain.
194194+195195+## References
196196+197197+- atproto repo spec: https://atproto.com/specs/repository
198198+- atproto DID method (PLC): https://web.plc.directory/
199199+- `@atproto/crypto`: https://www.npmjs.com/package/@atproto/crypto
200200+- MST spec: https://atproto.com/specs/repository#mst
201201+- Existing install code: `backend/electron/atproto-source.ts`
202202+- Existing blob-CID check: `atproto-source.ts:502` (`verifyBlobCid`)
+1
docs/tasks.md
···4343- [x] Atomic install with rollback on partial failure (temp dir + rename)
4444- [x] `minPeekVersion` enforcement on install
4545- [x] Icon/screenshot blob handling in publish.js
4646+- [ ] **Verify atproto record signatures.** (Gap found 2026-04-20.) `backend/electron/atproto-source.ts:356` fetches the release record via `com.atproto.repo.getRecord` and accepts the response with no signature check. A compromised or malicious PDS can forge a record with CIDs pointing at tampered bundle blobs, and `verifyBlobCid()` (line 502) will happily validate those tampered bytes against the forged CIDs. Current trust boundary is TLS to the PDS, not the publisher's signing key. Full design and step-by-step plan (commit-level signature, MST inclusion, `@atproto/crypto`, PLC key-rotation handling, icon/screenshot gap) in [atproto-signature-verification-plan.md](atproto-signature-verification-plan.md). ~3 days.
46474748### Pubsub audit follow-ups (2026-04-16)
4849- [x] Missing tag lifecycle events: `tag:created`, `tag:renamed`, `tag:deleted`
+4-2
schema/fidelity.test.js
···381381 assert.ok(tableNameMatch, 'TableName type should be defined in types/index.ts');
382382 const tsTableNames = tableNameMatch[1].match(/'([^']+)'/g).map(v => v.replace(/'/g, ''));
383383384384- // Extract CREATE TABLE names from Electron datastore (skip migration temp tables)
385385- const createTableRegex = /CREATE TABLE IF NOT EXISTS (\w+)/g;
384384+ // Extract CREATE TABLE names from Electron datastore (skip migration temp tables).
385385+ // Require the opening `(` of the column list so comments that mention
386386+ // "CREATE TABLE IF NOT EXISTS above" etc. don't register as tables.
387387+ const createTableRegex = /CREATE TABLE IF NOT EXISTS (\w+)\s*\(/g;
386388 const electronTables = new Set();
387389 let m;
388390 while ((m = createTableRegex.exec(electronSql)) !== null) {
+7-4
tests/unit/cmd-state-machine.test.js
···286286 assert.equal(machine.getState(), States.EXECUTING);
287287 });
288288289289- it('TYPING + Enter no match -> CLOSING (search)', () => {
289289+ it('TYPING + Enter no match -> TYPING (no-op, search is explicit)', () => {
290290 const result = machine.dispatch(Events.ENTER, {
291291 value: 'xyzzy',
292292 isURL: false,
293293 committed: false
294294 });
295295- assert.equal(machine.getState(), States.CLOSING);
295295+ assert.equal(machine.getState(), States.TYPING);
296296 });
297297298298 it('TYPING + Escape with text -> IDLE', () => {
···497497 assert.equal(machine.getState(), States.OUTPUT_SELECTION);
498498 });
499499500500- it('EXECUTING + command_complete (chainable output, has downstream) -> CHAIN_MODE', () => {
500500+ it('EXECUTING + command_complete (array output, has downstream) -> OUTPUT_SELECTION', () => {
501501+ // Array outputs always go to OUTPUT_SELECTION first even when downstream
502502+ // commands exist; the chain branch is taken on row selection. See the
503503+ // guard rationale in app/cmd/state-machine.js EXECUTING.COMMAND_COMPLETE.
501504 machine.dispatch(Events.COMMAND_COMPLETE, {
502505 result: { output: { data: [{ id: '1' }], mimeType: 'application/json' } },
503506 name: 'list',
504507 hasDownstream: true
505508 });
506506- assert.equal(machine.getState(), States.CHAIN_MODE);
509509+ assert.equal(machine.getState(), States.OUTPUT_SELECTION);
507510 });
508511509512 it('EXECUTING + command_complete (array, no downstream) -> OUTPUT_SELECTION', () => {