experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: ELECTRON_RUN_AS_NODE import compat, schema drift, stale cmd-state-machine tests, atproto plan

+256 -11
+21 -1
backend/electron/protocol.ts
··· 11 11 * - peek://local-file/{path} - Local filesystem files (images, documents, media, etc.) 12 12 */ 13 13 14 - import { protocol, net } from 'electron'; 14 + import { createRequire } from 'node:module'; 15 15 import path from 'node:path'; 16 16 import fs from 'node:fs'; 17 17 import { pathToFileURL } from 'node:url'; ··· 24 24 25 25 export const APP_SCHEME = 'peek'; 26 26 export const APP_PROTOCOL = `${APP_SCHEME}:`; 27 + 28 + // Lazy-load `protocol` and `net` via CommonJS require so this module can be 29 + // imported under ELECTRON_RUN_AS_NODE=1 (where electron's named ESM exports 30 + // are empty). Unit tests that only consume pure helpers (e.g. extension path 31 + // resolution via getExtensionPath) never touch the lazy binding. 32 + const requireElectron = createRequire(import.meta.url); 33 + let _electron: typeof import('electron') | null = null; 34 + function getElectron(): typeof import('electron') { 35 + if (_electron) return _electron; 36 + _electron = requireElectron('electron') as typeof import('electron'); 37 + return _electron; 38 + } 39 + const protocol: typeof import('electron').protocol = new Proxy( 40 + {} as typeof import('electron').protocol, 41 + { get: (_t, prop) => (getElectron().protocol as any)[prop] } 42 + ); 43 + const net: typeof import('electron').net = new Proxy( 44 + {} as typeof import('electron').net, 45 + { get: (_t, prop) => (getElectron().net as any)[prop] } 46 + ); 27 47 28 48 // Extension path cache: extensionId -> filesystem path 29 49 const extensionPaths = new Map<string, string>();
+17 -2
backend/electron/thumbnails.ts
··· 12 12 * - NativeImage released immediately after write 13 13 */ 14 14 15 - import { app, type WebContents } from 'electron'; 15 + import type { WebContents } from 'electron'; 16 + import { createRequire } from 'node:module'; 16 17 import { createHash } from 'node:crypto'; 17 18 import fs from 'node:fs'; 18 19 import path from 'node:path'; 19 20 import { DEBUG } from './config.js'; 20 21 import { normalizeUrl } from './datastore.js'; 22 + 23 + // Lazy-load `app` via CommonJS require so this module can be imported under 24 + // ELECTRON_RUN_AS_NODE=1 (where electron's named ESM exports are empty). 25 + // Consumers that never call initThumbnailDir/cleanupThumbnails (e.g. unit 26 + // tests that transitively import via protocol.ts -> tile-launcher.ts) won't 27 + // trigger the require. 28 + const requireElectron = createRequire(import.meta.url); 29 + let _app: typeof import('electron').app | null = null; 30 + function getApp(): typeof import('electron').app { 31 + if (_app) return _app; 32 + const electron = requireElectron('electron') as typeof import('electron'); 33 + _app = electron.app; 34 + return _app; 35 + } 21 36 22 37 let thumbnailDir: string; 23 38 ··· 63 78 * Ensure the thumbnails directory exists under userData. 64 79 */ 65 80 export function initThumbnailDir(): void { 66 - thumbnailDir = path.join(app.getPath('userData'), 'thumbnails'); 81 + thumbnailDir = path.join(getApp().getPath('userData'), 'thumbnails'); 67 82 if (!fs.existsSync(thumbnailDir)) { 68 83 fs.mkdirSync(thumbnailDir, { recursive: true }); 69 84 DEBUG && console.log('[thumbnails] Created thumbnail dir:', thumbnailDir);
+4 -2
backend/types/index.ts
··· 288 288 | 'item_events' 289 289 | 'item_groups' 290 290 | 'item_group_members' 291 - | 'settings'; 291 + | 'settings' 292 + | 'features_history'; 292 293 293 294 export const tableNames: TableName[] = [ 294 295 'content', ··· 306 307 'item_events', 307 308 'item_groups', 308 309 'item_group_members', 309 - 'settings' 310 + 'settings', 311 + 'features_history' 310 312 ]; 311 313 312 314 // ==================== Sync Types ====================
+202
docs/atproto-signature-verification-plan.md
··· 1 + # atproto Record Signature Verification Plan 2 + 3 + ## Gap 4 + 5 + `backend/electron/atproto-source.ts:356` fetches a `space.peek.feature.release` 6 + record via `com.atproto.repo.getRecord` and accepts whatever the PDS returns. 7 + No signature is checked. `verifyBlobCid()` (line 502) only confirms that the 8 + downloaded bundle bytes hash to the CIDs **declared by that record** — if the 9 + record itself was forged, the CIDs inside it can be anything. 10 + 11 + A compromised PDS (or a malicious party who controls the PDS answering for a 12 + DID) can: 13 + 14 + 1. Serve a forged record with CIDs that point at tampered bundle blobs. 15 + 2. Serve those tampered blobs when the client calls `com.atproto.sync.getBlob`. 16 + 3. The client's CID check passes because the CIDs in the forged record match 17 + the tampered bytes. 18 + 19 + Today's trust boundary is TLS to the PDS, not the publisher's signing key. 20 + 21 + ## How atproto signatures actually work 22 + 23 + Atproto does not sign records individually. It signs the **commit** at the 24 + top of the repo's Merkle Search Tree (MST). Record trust is transitive via 25 + MST inclusion. 26 + 27 + ### Commit object 28 + 29 + ``` 30 + commit { 31 + did, // the author's DID 32 + version, // repo format version (currently 3) 33 + data, // CID of the MST root 34 + rev, // tid — monotonic revision id 35 + prev, // CID of the previous commit (or null) 36 + sig, // raw signature bytes 37 + } 38 + ``` 39 + 40 + The signed message is `dag-cbor(commit-with-sig-field-removed)`. The signature 41 + is low-S ECDSA over SHA-256 of that encoding. Non-low-S signatures MUST be 42 + rejected. 43 + 44 + ### Signing key 45 + 46 + - `did:plc` → `https://plc.directory/<did>` returns the DID document. The 47 + `verificationMethod` entry with id `#atproto` carries the repo signing key 48 + as `publicKeyMultibase`. 49 + - `did:web` → `https://<host>/.well-known/did.json`, same entry. 50 + - The multicodec prefix on the multibase-decoded key identifies the curve: 51 + - `0xe7 0x01` → secp256k1 52 + - `0x80 0x24` → P-256 53 + 54 + ### MST inclusion 55 + 56 + Each MST node is dag-cbor. Leaves hold `{key, value: CID}` pairs. Re-hashing 57 + a node's encoding must produce the CID the parent node points at. Record 58 + trust = "commit is valid AND the record's CID appears at the correct leaf 59 + under commit.data." 60 + 61 + ## Verification steps the client must perform 62 + 63 + 1. **Resolve DID → DID doc.** Extract PDS endpoint AND the `#atproto` 64 + signing key. 65 + 2. **`com.atproto.sync.getLatestCommit(did)`** → `{cid, rev}`. 66 + 3. **`com.atproto.sync.getBlocks([commitCid])`** → raw CBOR of the commit. 67 + 4. **Verify commit signature.** 68 + - Decode commit CBOR. 69 + - Extract `sig` field. 70 + - Re-encode the commit minus `sig` as dag-cbor → message. 71 + - ECDSA-verify `(sha256(message), sig, publicKey)` using the curve 72 + indicated by the multicodec prefix. Enforce low-S. 73 + - If fail → abort install. 74 + 5. **Walk MST from `commit.data`** to the leaf keyed by 75 + `space.peek.feature.release/<rkey>`. At each node, fetch via `getBlocks`, 76 + re-hash, confirm against the parent's CID. If any hash mismatches → abort. 77 + 6. **Cross-check record CID.** The leaf's `value` CID must match the CID of 78 + the record bytes returned by `getRecord` (or preferably: skip `getRecord` 79 + entirely and fetch the record CBOR via `getBlocks` at the leaf's value 80 + CID, then validate its schema). 81 + 7. **Now `verifyBlobCid()` closes the chain.** Publisher's key → commit 82 + signature → MST inclusion → record CID → blob CIDs → blob bytes. 83 + 84 + ## Implementation approach 85 + 86 + ### Library 87 + 88 + `@atproto/crypto` (npm, maintained by Bluesky) gives us: 89 + - Parsing of `publicKeyMultibase` for both curves 90 + - Low-S ECDSA verification for secp256k1 and P-256 91 + 92 + That's the smallest reasonable dependency. The MST walker is small enough to 93 + hand-roll against the spec if we want to keep the install path lean; otherwise 94 + `@atproto/repo` has one. Decision: **vendor `@atproto/crypto` only, hand-roll 95 + MST walk**, to keep the install path's dependency surface tight. 96 + 97 + ### File layout 98 + 99 + - `backend/electron/atproto-verify.ts` — new module: 100 + - `resolveSigningKey(did): {curve, publicKey}` — DID doc fetch + key 101 + extraction. 102 + - `fetchSignedCommit(pdsUrl, did): {commit, commitCid, sig, signedBytes}`. 103 + - `verifyCommitSignature(signedBytes, sig, signingKey): boolean`. 104 + - `verifyRecordInclusion(pdsUrl, commit, collection, rkey): recordCid`. 105 + - `fetchAndVerifyRecord(atUri): verifiedRecord` — the public entry point 106 + the installer calls instead of `getRecord`. 107 + - `backend/electron/atproto-source.ts` — replace the naive `getRecord` call 108 + in `resolveInternal()` with `fetchAndVerifyRecord()`. Keep the existing 109 + `verifyBlobCid()` unchanged; it remains the last link in the chain. 110 + 111 + ### Key rotation (did:plc) 112 + 113 + A conservative client rejects a commit signed by a key that wasn't the key 114 + **active at the commit's `rev`** according to the PLC audit log (available 115 + at `https://plc.directory/<did>/log`). This prevents a stolen historical key 116 + from being used to forge new commits. 117 + 118 + Phased rollout: 119 + 120 + - **Phase 1**: verify against the *current* `#atproto` key from the DID doc. 121 + This is what most atproto clients do today. Big step up from the status 122 + quo; ships the feature. 123 + - **Phase 2**: walk the PLC audit log, find the key active at the commit's 124 + `rev`, verify against *that*. Adds ~50 lines and one extra HTTP call but 125 + removes the stolen-old-key attack. 126 + 127 + ### Icon / screenshot blobs 128 + 129 + Currently unreferenced from the `space.peek.feature.release` record, so they 130 + are **not covered** by any CID or signature chain. A PDS can substitute them 131 + freely. Two options: 132 + 133 + 1. **Include them in the lexicon.** Add `icon: blob` and 134 + `screenshots: blob[]` fields to `space.peek.feature.release`. `publish.js` 135 + already uploads these as blobs; just wire their CIDs into the record. 136 + Existing CID validation then covers them. 137 + 2. **Document as cosmetic.** If we decide icons/screenshots are not 138 + security-sensitive (they never execute), note that in the lexicon doc and 139 + accept the cosmetic-substitution risk. 140 + 141 + Recommend option 1 — cost is trivial once the signature chain exists. 142 + 143 + ### Caching 144 + 145 + A verified record is safe to cache on disk keyed by `(did, collection, rkey, 146 + commitCid)`. Invalidate when `getLatestCommit` returns a newer `rev`. Saves 147 + repeated MST walks on update checks. 148 + 149 + ### Failure modes 150 + 151 + Every verification failure MUST abort install and surface a clear error: 152 + 153 + - `DID resolution failed` — network or malformed DID doc. 154 + - `Signing key not found` — DID doc has no `#atproto` verificationMethod. 155 + - `Unsupported key curve` — multicodec prefix outside {secp256k1, P-256}. 156 + - `Commit signature invalid` — the big one. Means the PDS is lying or the 157 + publisher's key was compromised. 158 + - `MST inclusion failed` — commit is valid but doesn't contain the claimed 159 + record; PDS is lying about which record exists. 160 + - `Record CID mismatch` — record bytes don't match the leaf's value CID. 161 + - `Blob CID mismatch` — already covered by existing `verifyBlobCid()`. 162 + 163 + Each should log the DID, the atUri, and the reason before aborting. Do not 164 + fall back to unverified install under any circumstance. 165 + 166 + ## Testing 167 + 168 + - **Unit tests** (`backend/electron/atproto-verify.test.ts`): 169 + - Golden signed commit fixture → verifies cleanly. 170 + - Flipped bit in commit body → rejects. 171 + - Flipped bit in `sig` → rejects. 172 + - Non-low-S signature → rejects. 173 + - Wrong curve in DID doc vs signature → rejects with useful message. 174 + - MST leaf at the wrong path → rejects. 175 + - Tampered MST node hash → rejects. 176 + - **Integration test**: stand up a local PDS fixture (or record a real one), 177 + happy-path install, then a fault-injection test that mutates one byte of 178 + the bundle and asserts install aborts with `Blob CID mismatch`. 179 + - **Adversary test**: run the installer against a hand-crafted "malicious 180 + PDS" fixture that returns a forged record with CIDs pointing at tampered 181 + blobs the fixture also serves. Must abort at commit signature verification, 182 + not after downloading anything significant. 183 + 184 + ## Scope and cost estimate 185 + 186 + - Core signature + MST verification: ~200–300 lines. 187 + - `@atproto/crypto` dependency add: trivial. 188 + - Test fixtures (golden commit, malicious PDS): ~1 day. 189 + - Phase 1 (current key): ~2 days including tests. 190 + - Phase 2 (PLC log walking): ~0.5 day once Phase 1 ships. 191 + - Icon/screenshot lexicon extension: ~0.5 day. 192 + 193 + Total: ~3 days for a complete tamper-evident install chain. 194 + 195 + ## References 196 + 197 + - atproto repo spec: https://atproto.com/specs/repository 198 + - atproto DID method (PLC): https://web.plc.directory/ 199 + - `@atproto/crypto`: https://www.npmjs.com/package/@atproto/crypto 200 + - MST spec: https://atproto.com/specs/repository#mst 201 + - Existing install code: `backend/electron/atproto-source.ts` 202 + - Existing blob-CID check: `atproto-source.ts:502` (`verifyBlobCid`)
+1
docs/tasks.md
··· 43 43 - [x] Atomic install with rollback on partial failure (temp dir + rename) 44 44 - [x] `minPeekVersion` enforcement on install 45 45 - [x] Icon/screenshot blob handling in publish.js 46 + - [ ] **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. 46 47 47 48 ### Pubsub audit follow-ups (2026-04-16) 48 49 - [x] Missing tag lifecycle events: `tag:created`, `tag:renamed`, `tag:deleted`
+4 -2
schema/fidelity.test.js
··· 381 381 assert.ok(tableNameMatch, 'TableName type should be defined in types/index.ts'); 382 382 const tsTableNames = tableNameMatch[1].match(/'([^']+)'/g).map(v => v.replace(/'/g, '')); 383 383 384 - // Extract CREATE TABLE names from Electron datastore (skip migration temp tables) 385 - const createTableRegex = /CREATE TABLE IF NOT EXISTS (\w+)/g; 384 + // Extract CREATE TABLE names from Electron datastore (skip migration temp tables). 385 + // Require the opening `(` of the column list so comments that mention 386 + // "CREATE TABLE IF NOT EXISTS above" etc. don't register as tables. 387 + const createTableRegex = /CREATE TABLE IF NOT EXISTS (\w+)\s*\(/g; 386 388 const electronTables = new Set(); 387 389 let m; 388 390 while ((m = createTableRegex.exec(electronSql)) !== null) {
+7 -4
tests/unit/cmd-state-machine.test.js
··· 286 286 assert.equal(machine.getState(), States.EXECUTING); 287 287 }); 288 288 289 - it('TYPING + Enter no match -> CLOSING (search)', () => { 289 + it('TYPING + Enter no match -> TYPING (no-op, search is explicit)', () => { 290 290 const result = machine.dispatch(Events.ENTER, { 291 291 value: 'xyzzy', 292 292 isURL: false, 293 293 committed: false 294 294 }); 295 - assert.equal(machine.getState(), States.CLOSING); 295 + assert.equal(machine.getState(), States.TYPING); 296 296 }); 297 297 298 298 it('TYPING + Escape with text -> IDLE', () => { ··· 497 497 assert.equal(machine.getState(), States.OUTPUT_SELECTION); 498 498 }); 499 499 500 - it('EXECUTING + command_complete (chainable output, has downstream) -> CHAIN_MODE', () => { 500 + it('EXECUTING + command_complete (array output, has downstream) -> OUTPUT_SELECTION', () => { 501 + // Array outputs always go to OUTPUT_SELECTION first even when downstream 502 + // commands exist; the chain branch is taken on row selection. See the 503 + // guard rationale in app/cmd/state-machine.js EXECUTING.COMMAND_COMPLETE. 501 504 machine.dispatch(Events.COMMAND_COMPLETE, { 502 505 result: { output: { data: [{ id: '1' }], mimeType: 'application/json' } }, 503 506 name: 'list', 504 507 hasDownstream: true 505 508 }); 506 - assert.equal(machine.getState(), States.CHAIN_MODE); 509 + assert.equal(machine.getState(), States.OUTPUT_SELECTION); 507 510 }); 508 511 509 512 it('EXECUTING + command_complete (array, no downstream) -> OUTPUT_SELECTION', () => {