Archipelago — open-source initial import
This commit is contained in:
+151
@@ -0,0 +1,151 @@
|
||||
---
|
||||
phase: quick-260729-fw7
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- neode-ui/src/views/mesh/HopVizModal.vue
|
||||
- neode-ui/src/views/Mesh.vue
|
||||
- neode-ui/src/views/mesh/mesh-styles.css
|
||||
autonomous: true
|
||||
requirements: [QUICK-FW7]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Clicking a mesh message's transport pill (or the ⋯ button) opens a hop-route modal that is visually balanced on desktop — the hop chain fills the modal width, endpoint nodes are prominent, and the modal is no longer a cramped 420px card with tiny emoji + dot row"
|
||||
- "On narrow viewports (phone width) the hop chain renders VERTICALLY — sender at top, relays stacked, recipient at bottom — with the packet animation traveling top-to-bottom, nothing overflowing or wrapping awkwardly"
|
||||
- "The visualization uses Archipelago's design language: glass panel, dark-only palette, per-transport accent colors (meshcore orange #fb923c, meshtastic mint #3eb489, reticulum blue #60a5fa, lora amber #f59e0b, fips violet #a78bfa, tor indigo #818cf8), EQ-segment-style bars echoing ScreensaverRing, node glow, staggered reveal, animated packet traveling the path"
|
||||
- "prefers-reduced-motion disables all looping animations (existing behavior preserved)"
|
||||
- "Tor / FIPS / unknown-transport cases still render their distinct shapes (3 anonymous relays / direct P2P / not recorded), and SNR/RSSI + E2E/delivery metadata still display"
|
||||
artifacts:
|
||||
- "neode-ui/src/views/mesh/HopVizModal.vue — new self-contained modal component (Teleport to body) with scoped styles"
|
||||
- "neode-ui/src/views/Mesh.vue — inline hop-viz modal markup replaced by <HopVizModal>"
|
||||
- "web/dist/neode-ui/ — rebuilt bundle containing the new component's class strings"
|
||||
key_links:
|
||||
- "Mesh.vue transport pill / ⋯ button click → hopVizMsg → <HopVizModal :msg :peer @close> renders"
|
||||
- "HopVizModal derives accent color from msg.transport, matching the pill colors in mesh-styles.css lines 168-173"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Redesign the mesh-message hop visualization modal in neode-ui: properly sized and balanced on desktop, vertical stacked layout on mobile, and fully on-brand (glass, per-transport accents, EQ-segment motif from ScreensaverRing, animated packet travel, node glow, staggered reveal).
|
||||
|
||||
User feedback (verbatim): "please make the hop graphic and animation on mesh messages much better balanced, the desktop one is very small and it doesn't work on mobile where it should be vertical, and make it much more archipelago style and branded, make it beautiful."
|
||||
|
||||
Purpose: The current hop viz (inline in Mesh.vue lines ~2627-2678, styles in mesh-styles.css lines ~597-637) is a cramped horizontal flex row inside a 420px modal — tiny emoji endpoints, a dashed border with blinking `•` dots, no mobile handling. It reads as an afterthought, not an Archipelago feature.
|
||||
|
||||
Output: New `HopVizModal.vue` component wired into Mesh.vue, old inline markup and `.mesh-hopviz-*` CSS removed, rebuilt bundle in web/dist/neode-ui/ verified to contain the new strings.
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
|
||||
**Current implementation (read all of these first):**
|
||||
- `neode-ui/src/views/Mesh.vue` lines ~1291-1310 (hopVizMsg / hopVizPeer / hopVizHops state), lines ~2340-2350 (transport pill + ⋯ button that set `hopVizMsg`), lines ~2627-2678 (the inline Teleported modal to replace). Also `transportLabel()` and `signalQualityLabel()` helpers used by the modal — grep for them in Mesh.vue.
|
||||
- `neode-ui/src/views/mesh/mesh-styles.css` lines ~160-175 (transport pill accent colors + `.mesh-chat-e2e`), ~572-584 (`.mesh-transport-modal-backdrop`, `.mesh-transport-modal`, title/sub/cancel — SHARED with the send-transport and image-quality modals, do not break them), ~597-637 (`.mesh-hopviz-*` rules to delete/migrate).
|
||||
- `neode-ui/src/components/ScreensaverRing.vue` — the brand EQ-segment motif: thin 4px rounded bars, white gradient fill, staggered `scaleY` pulse keyframes. Echo this visual language for relay-node markers.
|
||||
- `neode-ui/tailwind.config.js` — glass tokens (glass-dark/glass-border/shadow-glass), fonts: `Montserrat` = `font-archipelago` header font; app is dark-only.
|
||||
- Global focus glow + accent used app-wide: orange `rgba(251,146,60,…)` (#fb923c).
|
||||
|
||||
**Transport accent map (must match pill colors):** meshtastic `#3eb489`, meshcore `#fb923c`, reticulum `#60a5fa`, lora `#f59e0b`, fips `#a78bfa`, tor `#818cf8`.
|
||||
|
||||
**Data realities:** For LoRa transports only a hop COUNT is known (`peer.hops`, 0 or 0xff/null = direct), not per-relay identities — render count relays as anonymous branded markers. Tor = fixed "3 anonymous relays" shape. FIPS = direct P2P. `null` transport = not recorded. SNR/RSSI are current link readings (keep the existing disclaimer note).
|
||||
|
||||
**Project rules that apply:**
|
||||
- Modals MUST `<Teleport to="body">` with full-screen backdrop (already true — preserve it).
|
||||
- Frontend build can silently no-op: after `npm run build`, grep the built bundle in `web/dist/neode-ui/` for new strings before claiming done.
|
||||
- Commit the code changes when they work; push via `git push gitea-ai main`. Stage explicitly by path (`git add <paths>`), never `git add -A` — other agents may share the tree.
|
||||
- Do not deploy to any node; this rides the normal dev-pair → OTA pipeline later.
|
||||
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Build HopVizModal.vue — branded, balanced desktop hop visualization</name>
|
||||
<files>neode-ui/src/views/mesh/HopVizModal.vue, neode-ui/src/views/Mesh.vue, neode-ui/src/views/mesh/mesh-styles.css</files>
|
||||
<action>
|
||||
Create `neode-ui/src/views/mesh/HopVizModal.vue` (script setup, TypeScript) and move the hop-viz modal out of Mesh.vue into it.
|
||||
|
||||
Component contract:
|
||||
- Props: `msg: MeshMessage` (import type from `../../types/api` or wherever Mesh.vue imports it), `peer: MeshPeer | null`, plus the two label strings Mesh.vue already computes (`transportLabel` result and `signalQualityLabel` result) OR import/reuse those helpers if they are importable; if they are local functions in Mesh.vue, pass computed strings as props — do NOT duplicate logic.
|
||||
- Emits: `close`.
|
||||
- Template: `<Teleport to="body">` wrapping a full-screen backdrop (reuse `.mesh-transport-modal-backdrop` class so backdrop behavior stays consistent) with `@click.self="emit('close')"`, containing a `glass-card` panel.
|
||||
|
||||
Visual redesign (all styles SCOPED in the component; delete the old `.mesh-hopviz-*` rules from mesh-styles.css lines ~597-637 — but leave `.mesh-transport-modal-backdrop`, `.mesh-transport-modal`, `.mesh-transport-title/sub/cancel` untouched since the send-transport and image-quality modals still use them; the new panel should use its own width class, wider than 420px — target `min(560px, 94vw)` on desktop):
|
||||
|
||||
1. **Header:** transport-colored title using the Montserrat brand font (`font-family: 'Montserrat', sans-serif` or the `font-archipelago` utility), e.g. "MeshCore route", with the existing You → peer subtitle. Derive an `--hop-accent` CSS custom property on the panel root from `msg.transport` using the exact pill color map (meshtastic #3eb489, meshcore #fb923c, reticulum #60a5fa, lora #f59e0b, fips #a78bfa, tor #818cf8, fallback rgba(251,146,60) orange). All accents below use `var(--hop-accent)`.
|
||||
|
||||
2. **Endpoint nodes (You / peer):** substantial circular medallions (~64-72px) instead of bare emoji — island glyph 🏝️ centered inside a ring of 12-16 EQ-style segments (thin rounded bars radiating like a compact ScreensaverRing — reuse its technique: absolutely-positioned bars, `transform: rotate(deg) translateY(-radius)`, staggered `scaleY` pulse animation, white-to-transparent gradient tinted with the accent). Soft accent glow behind each medallion (`box-shadow: 0 0 24px color-mix(...)` or an rgba shadow). Node name below in white 600-weight, ellipsized.
|
||||
|
||||
3. **Path between endpoints:** replace the dashed-border + blinking `•` row with a proper track: a horizontal line/gradient in the accent color connecting the medallions, with:
|
||||
- Relay markers for each hop (LoRa transports: `Math.min(hops, 6)` markers; Tor: exactly 3 with a 🧅/anonymous treatment; FIPS: no relays, a single direct link) rendered as small EQ-segment clusters or glowing accent dots (~10-14px) sitting ON the track, each with its own subtle pulse, staggered.
|
||||
- An animated packet: a small bright dot/comet (accent color, blurred glow trail) traveling from sender to recipient along the track on an infinite ~2s loop. Use a CSS keyframe translating along the track container (like the existing `mesh-hopviz-travel` sweep but as a discrete glowing packet, not a background sheen).
|
||||
- Label under/over the track: "direct radio link" / "N hops" / "3 anonymous relays" / "direct peer-to-peer" / "transport wasn't recorded" — preserve the existing per-transport template branches and copy.
|
||||
- Staggered entrance: sender medallion, then track+relays, then recipient fade/slide in (keep the existing appear pattern, ~0.05/0.35/0.65s delays).
|
||||
|
||||
4. **Metadata footer:** keep the SNR/RSSI signal row (LoRa only, with the existing "current link readings" disclaimer note) and the E2E / delivered ✓✓ / timestamp row, restyled as small glass chips consistent with `.mesh-transport-meta` sizing. Keep the Close button (`.mesh-transport-cancel` class is fine).
|
||||
|
||||
5. **Reduced motion:** wrap ALL looping animations (packet, segment pulses, relay pulses) and entrance animations in the component's own `@media (prefers-reduced-motion: reduce)` block that disables them — the old CSS did this; the new component must too.
|
||||
|
||||
Wire-up in Mesh.vue: import HopVizModal, replace the inline `<Teleport>` block at lines ~2627-2678 with `<HopVizModal v-if="hopVizMsg" :msg="hopVizMsg" :peer="hopVizPeer" ... @close="hopVizMsg = null" />`. Keep `hopVizMsg`/`hopVizPeer`/`hopVizHops` state in Mesh.vue (or move `hopVizHops` into the component — it only needs `peer.hops`; prefer moving it in to shrink Mesh.vue). Do not touch the transport pill / ⋯ button triggers.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/archipelago/Projects/archy/neode-ui && npx vue-tsc --noEmit 2>/dev/null || npm run build</automated>
|
||||
</verify>
|
||||
<done>HopVizModal.vue exists with the medallion + track + packet design, Mesh.vue renders it in place of the inline modal, old `.mesh-hopviz-*` rules removed from mesh-styles.css, other transport modals' shared classes untouched, type-check/build passes.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Mobile vertical layout</name>
|
||||
<files>neode-ui/src/views/mesh/HopVizModal.vue</files>
|
||||
<action>
|
||||
Add responsive behavior inside HopVizModal.vue's scoped styles. At narrow widths (`@media (max-width: 560px)` — pick the breakpoint so a typical phone portrait always gets it):
|
||||
- The chain flips to a COLUMN: sender medallion at top, vertical track with relay markers stacked below it, recipient medallion at bottom. Implement so the same DOM works in both orientations (flex-direction column + a track that switches from horizontal line to vertical line), rather than duplicating markup.
|
||||
- The packet animation travels TOP-TO-BOTTOM along the vertical track (a second keyframe or a transform-based animation that follows the flex axis).
|
||||
- Medallions may shrink slightly (~56px) but stay prominent; names and hop label must not truncate mid-word or overflow the panel; panel uses near-full width (`width: 94vw`) with comfortable vertical padding, and the whole modal scrolls (`max-height: 90vh; overflow-y: auto`) if metadata pushes it tall.
|
||||
- Entrance stagger and reduced-motion handling apply identically in vertical mode.
|
||||
Sanity-check both orientations in the browser via the dev preview (`npm run dev`, viewport toggling in devtools) if a display is available; otherwise rely on the CSS being purely breakpoint-driven and symmetric.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/archipelago/Projects/archy/neode-ui && grep -q "max-width: 560px" src/views/mesh/HopVizModal.vue && grep -qi "column" src/views/mesh/HopVizModal.vue</automated>
|
||||
</verify>
|
||||
<done>Below the breakpoint the hop chain renders vertically (sender top → recipient bottom) with the packet traveling downward; no overflow; desktop layout unchanged above the breakpoint.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Build, verify bundle, commit</name>
|
||||
<files>web/dist/neode-ui/ (build output), neode-ui/src/views/mesh/HopVizModal.vue</files>
|
||||
<action>
|
||||
1. `cd neode-ui && npm run build` (outputs to `web/dist/neode-ui/`).
|
||||
2. Per CLAUDE.md, prove the build actually picked up the change: grep the built JS/CSS bundle for a new unique string from the component (e.g. a distinctive class name like `hopviz-medallion` or `hopviz-packet` — whatever class names Task 1 used; pick one that did not exist before): `grep -rl "hopviz-packet" web/dist/neode-ui/assets/` (adjust the token to the actual class name). It MUST match; if it doesn't, the build silently no-opped — investigate before proceeding.
|
||||
3. Also confirm the OLD inline markup is gone from the bundle source of truth: `grep -c "mesh-hopviz-chain" neode-ui/src/views/Mesh.vue` returns 0.
|
||||
4. Commit the code changes only (docs/planning files are committed by the orchestrator): `git add neode-ui/src/views/mesh/HopVizModal.vue neode-ui/src/views/Mesh.vue neode-ui/src/views/mesh/mesh-styles.css web/dist/neode-ui` — stage exactly these paths, never `git add -A`. Check `git status` first for other agents' unrelated changes and leave them alone. Commit message: `feat(mesh): redesign hop-route visualization — branded, animated, vertical on mobile` ending with the `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>` trailer. Push: `git push gitea-ai main`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -rl "hopviz" /home/archipelago/Projects/archy/web/dist/neode-ui/assets/ | head -1 && cd /home/archipelago/Projects/archy && git log --oneline -1 | grep -qi "hop"</automated>
|
||||
</verify>
|
||||
<done>Fresh build in web/dist/neode-ui/ contains the new component's class strings, Mesh.vue no longer contains the old inline hopviz markup, and the change is committed and pushed via gitea-ai.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `npm run build` succeeds in neode-ui/; bundle in web/dist/neode-ui/ contains a new hopviz class string (silent-no-op guard).
|
||||
- Old `.mesh-hopviz-*` rules removed; `.mesh-transport-modal-backdrop` / `.mesh-transport-option` / image-quality modal styles untouched (grep mesh-styles.css and open the send-transport modal path in code to confirm shared classes intact).
|
||||
- Modal still Teleports to body with full-screen backdrop.
|
||||
- All five transport branches render: meshcore/meshtastic/reticulum (hop count), tor (3 relays), fips (direct), null (not recorded).
|
||||
- prefers-reduced-motion block present in the new component.
|
||||
<human-check>On the dev preview (:8100 or `npm run dev`), open Mesh chat, click a message's transport pill: desktop shows the wide balanced medallion+packet layout; shrinking the window below the breakpoint flips it vertical. Confirm it "feels Archipelago" — glass, accent glow, EQ-segment motif.</human-check>
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Desktop hop modal is visually balanced: ~560px panel, prominent glowing endpoint medallions with EQ-segment rings, accent-colored track with animated traveling packet and staggered relay markers.
|
||||
- Mobile (< 560px) renders the chain vertically top-to-bottom with the packet traveling downward; nothing overflows.
|
||||
- Per-transport accent colors match the existing transport pill colors exactly.
|
||||
- Reduced-motion users get a static layout.
|
||||
- Built bundle verified to contain the new strings; code committed and pushed via gitea-ai.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Executor commits code only. On completion, note results for the orchestrator; no SUMMARY.md required for quick mode unless the orchestrator asks.
|
||||
</output>
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
---
|
||||
phase: quick-260729-fw7
|
||||
plan: 01
|
||||
subsystem: neode-ui/mesh
|
||||
tags: [frontend, mesh, hop-viz, branding, animation, responsive]
|
||||
requires: []
|
||||
provides:
|
||||
- HopVizModal.vue branded hop-route visualization component
|
||||
affects:
|
||||
- neode-ui mesh chat (transport pill / ⋯ route modal)
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- Self-contained Teleport-to-body modal component with scoped styles
|
||||
- EQ-segment ring motif (ScreensaverRing technique) reused for endpoint medallions
|
||||
- CSS custom property accent theming (--hop-accent) derived per transport in JS
|
||||
key-files:
|
||||
created:
|
||||
- neode-ui/src/views/mesh/HopVizModal.vue
|
||||
modified:
|
||||
- neode-ui/src/views/Mesh.vue
|
||||
- neode-ui/src/views/mesh/mesh-styles.css
|
||||
decisions:
|
||||
- "Label strings (transportLabel/signalQualityLabel/timeAgo) passed as props from Mesh.vue — no logic duplication"
|
||||
- "hopVizHops moved into the component (derived from peer.hops); hopVizMsg/hopVizPeer state stays in Mesh.vue"
|
||||
- "web/dist/neode-ui NOT committed — web/ is gitignored (.gitignore:74); build output is intentionally untracked in this repo"
|
||||
metrics:
|
||||
duration: ~15m
|
||||
completed: 2026-07-29
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick Task 260729-fw7: Mesh Hop-Route Visualization Redesign Summary
|
||||
|
||||
**One-liner:** Replaced the cramped 420px inline hop-viz modal with a self-contained branded HopVizModal.vue — 560px balanced desktop layout with EQ-segment-ringed glowing medallions, per-transport accent track with animated traveling packet and staggered relay markers, flipping to a vertical sender-top→recipient-bottom chain below 560px.
|
||||
|
||||
## What Was Built
|
||||
|
||||
- **`neode-ui/src/views/mesh/HopVizModal.vue`** (new, ~430 lines): Teleport-to-body modal, `min(560px, 94vw)` glass panel, `max-height: 90vh` scrollable.
|
||||
- Montserrat transport-colored title; `--hop-accent` / `--hop-accent-soft` / `--hop-accent-faint` CSS vars derived from `msg.transport` matching the chat pill colors exactly (meshtastic #3eb489, meshcore #fb923c, reticulum #60a5fa, lora #f59e0b, fips #a78bfa, tor #818cf8, fallback orange).
|
||||
- Endpoint medallions: 72px (56px mobile), island glyph on an accent-tinted disc with glow, ringed by 14 EQ segments using the ScreensaverRing rotate+translateY+scaleY-pulse technique.
|
||||
- Track: accent gradient line, relay markers positioned fractionally along it — mini 3-bar EQ clusters for radio hops (`min(hops, 6)`), 🧅 ×3 for Tor, none for FIPS/unknown (unknown dims the line). Animated white/accent glowing packet travels sender→recipient on a 2.2s loop.
|
||||
- Per-transport labels preserved verbatim: "direct radio link" / "N hops" / "3 anonymous relays" / "FIPS overlay · direct peer-to-peer" / "transport wasn't recorded".
|
||||
- Staggered entrance (0.05/0.35/0.65s), metadata as glass chips (signal + SNR/RSSI + disclaimer note; E2E badge + delivered ✓✓ + time).
|
||||
- `@media (max-width: 560px)`: chain flips to column, track becomes a vertical line, packet animates top→bottom (`hopviz-packet-y`), same DOM.
|
||||
- `@media (prefers-reduced-motion: reduce)`: all loops and entrance animations disabled, packet hidden.
|
||||
- **`Mesh.vue`**: inline 50-line Teleport block replaced by `<HopVizModal>`; label helpers passed as computed props; `hopVizHops()` removed (moved into component).
|
||||
- **`mesh-styles.css`**: all `.mesh-hopviz-*` rules and their keyframes/reduced-motion block deleted; `.mesh-chat-transport-clickable`, `.mesh-transport-modal-*` (shared with send-transport + image-quality modals), and `.mesh-chat-more-btn` untouched (12 shared-class occurrences verified intact).
|
||||
|
||||
## Commits
|
||||
|
||||
| Task | Commit | Description |
|
||||
| ---- | ------ | ----------- |
|
||||
| 1–3 | `ac09fc5d` | feat(mesh): redesign hop-route visualization — branded, animated, vertical on mobile |
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx vue-tsc --noEmit` → exit 0.
|
||||
- `npm run build` → success; bundle guard (CLAUDE.md silent-no-op rule): `hopviz-packet` and `hopviz-medallion` found in `web/dist/neode-ui/assets/Mesh-D2ImmPoh.js` + `Mesh-Dy7zKwro.css`; old `mesh-hopviz-chain` string absent from both source and bundle.
|
||||
- Task 2 grep checks: `max-width: 560px` + `column` present in HopVizModal.vue.
|
||||
- Submodule guard run before commit (no indeedhub paths staged); no file deletions in the commit.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
**1. [Rule 3 - Blocking] `web/dist/neode-ui` not staged/committed**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** Plan said to stage `web/dist/neode-ui`, but `web/` is gitignored (`.gitignore:74`) and untracked — build output is intentionally excluded from the repo (release tarballs are built from it at ship time).
|
||||
- **Fix:** Committed the three source files only; fresh build exists on disk in `web/dist/neode-ui/` and was grep-verified.
|
||||
|
||||
**2. Push deferred to orchestrator** — plan Task 3 said `git push gitea-ai main`, but the executor constraints state the orchestrator handles pushing; not pushed here.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None — no placeholder text, hardcoded-empty data paths, or unwired components introduced.
|
||||
|
||||
## Human Verification Pending
|
||||
|
||||
On the dev preview (`npm run dev` or :8100), open a mesh chat and click a message's transport pill: desktop shows the wide medallion+packet layout; shrinking below 560px flips it vertical. Confirm brand feel (glass, accent glow, EQ motif).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: neode-ui/src/views/mesh/HopVizModal.vue
|
||||
- FOUND: commit ac09fc5d on main
|
||||
- FOUND: hopviz strings in web/dist/neode-ui/assets/ (fresh build)
|
||||
- CONFIRMED: 0 occurrences of `mesh-hopviz` in Mesh.vue and mesh-styles.css
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
---
|
||||
phase: quick-260729-gjd
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- neode-ui/docker/nginx-demo.conf
|
||||
- neode-ui/docker/indee-demo-signin.js
|
||||
- neode-ui/Dockerfile.web
|
||||
- docker-compose.demo.yml
|
||||
- demo-deploy/docker-compose.yml
|
||||
- neode-ui/src/composables/useDemoIntro.ts
|
||||
- neode-ui/src/views/appSession/useAppIdentity.ts
|
||||
- neode-ui/mock-backend.js
|
||||
autonomous: true
|
||||
requirements: [QUICK-260729-GJD]
|
||||
must_haves:
|
||||
truths:
|
||||
- "A fresh demo session (clean browser, no localStorage) shows IndeeHub as an installed, running app in My Apps"
|
||||
- "Launching IndeeHub in the demo renders the real indee.tx1138.com site inside the in-app iframe session (no new tab, no external interstitial)"
|
||||
- "The embedded IndeeHub boots signed-in (active demo account visible, no login wall) and no identity-picker modal blocks the demo visitor"
|
||||
- "The non-demo (real node) build is byte-for-byte unaffected in behavior: indeedhub launch, identity picker, and NIP-07 bridge all work as before"
|
||||
- "The served demo content contains no occurrence of the private release-server IP (existing Docker build guards still pass)"
|
||||
artifacts:
|
||||
- "neode-ui/docker/nginx-demo.conf — new whole-origin reverse-proxy server block (port 2101) for indee.tx1138.com with framing headers stripped and sign-in script injected"
|
||||
- "neode-ui/docker/indee-demo-signin.js — demo-only localStorage seeding script with a labelled throwaway demo nsec"
|
||||
- "docker-compose.demo.yml and demo-deploy/docker-compose.yml — publish the new 2101 port"
|
||||
- "neode-ui/src/composables/useDemoIntro.ts — indeedhub moved from external-tab to iframe launch via the :2101 proxy origin"
|
||||
- "neode-ui/mock-backend.js — indeedhub present in staticDevApps as installed/running"
|
||||
key_links:
|
||||
- "demoAppUrl('indeedhub') → http://<demo-host>:2101/ → nginx :2101 server block → https://indee.tx1138.com upstream"
|
||||
- "nginx sub_filter → /__demo/indee-demo-signin.js → seeds indeedhub-accounts + indeedhub-active-account localStorage keys → IndeeHub boot-restore logs the visitor in"
|
||||
- "staticDevApps['indeedhub'] → structuredClone into per-session package-data → My Apps grid on fresh session"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Make IndeeHub a first-class app in the PUBLIC DEMO only: (1) the real site
|
||||
https://indee.tx1138.com/ renders inside the in-app iframe session (today it is
|
||||
frame-busted by `X-Frame-Options: SAMEORIGIN` and opens externally), (2) a demo
|
||||
visitor sees IndeeHub already signed in with a throwaway demo Nostr identity
|
||||
(no login wall, no identity-picker modal), and (3) IndeeHub appears as an
|
||||
already-installed, running app on a completely fresh demo session.
|
||||
|
||||
Purpose: the demo currently punts IndeeHub to a new tab with a login wall —
|
||||
the flagship media app looks broken/hostile to demo visitors.
|
||||
Output: demo-scoped changes across nginx-demo.conf, a new sign-in seed script,
|
||||
the two demo compose files, useDemoIntro.ts, useAppIdentity.ts, mock-backend.js.
|
||||
|
||||
All behavior changes are gated behind IS_DEMO / demo-image build paths. The
|
||||
real-node build must be completely unaffected.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@CLAUDE.md
|
||||
@neode-ui/src/composables/useDemoIntro.ts
|
||||
@neode-ui/docker/nginx-demo.conf
|
||||
@neode-ui/Dockerfile.web
|
||||
@neode-ui/Dockerfile.backend
|
||||
@docker-compose.demo.yml
|
||||
@demo-deploy/docker-compose.yml
|
||||
@neode-ui/src/views/appSession/appSessionConfig.ts
|
||||
@neode-ui/src/views/appSession/useAppIdentity.ts
|
||||
@neode-ui/src/views/appSession/useNostrBridge.ts
|
||||
@neode-ui/src/stores/appLauncher.ts
|
||||
@neode-ui/mock-backend.js
|
||||
</context>
|
||||
|
||||
<verified_findings>
|
||||
Facts confirmed by inspection on 2026-07-29 (do not re-derive, but re-verify
|
||||
the live-bundle details marked "verify at exec time"):
|
||||
|
||||
- `curl -sI https://indee.tx1138.com/` → `X-Frame-Options: SAMEORIGIN`, no
|
||||
Content-Security-Policy header today (strip both defensively).
|
||||
- The live index.html loads a hashed module bundle from absolute-root paths
|
||||
(`/assets/index-*.js`, `/icons/...`, `/manifest.json`). This is why the old
|
||||
`/app/indeedhub/` path-prefix + sub_filter proxy broke (asset/router paths
|
||||
escape the prefix). A WHOLE-ORIGIN proxy on a dedicated port has no such
|
||||
problem — the SPA sees itself at `/` and every relative call just works.
|
||||
- The live bundle (assets/index-BMWtjRCn.js) uses applesauce-accounts:
|
||||
- localStorage key `"indeedhub-accounts"` = JSON array of serialized
|
||||
accounts (`Fe.toJSON`/`Fe.fromJSON`), restored on boot before UI renders.
|
||||
- localStorage key `"indeedhub-active-account"` = active account id.
|
||||
- A private-key account class exists whose `fromJSON` does
|
||||
`const t=Vn(e.signer.key); new mr(e.pubkey, new ai(t))` — i.e. shape is
|
||||
`{ id, type: "<verify at exec time>", pubkey, signer: { key: "<hex sk>" } }`
|
||||
plus common fields from `loadCommonFields` (verify exact `type` string and
|
||||
common fields by grepping the live bundle for `static type` / the class's
|
||||
`toJSON`). An `"extension"` account type also exists (fallback path).
|
||||
- The bundle expects NIP-07 as a real `window.nostr` object ("Signer
|
||||
extension missing" guard) — it does NOT contain the archipelago
|
||||
`nostr-request` postMessage client.
|
||||
- Parent-side NIP-07 plumbing already exists: `AppSession.vue` line ~423
|
||||
routes `nostr-request` messages to `useNostrBridge`, which calls
|
||||
`node.nostr-pubkey` (mocked in mock-backend.js) and `node.nostr-sign`
|
||||
(NOT implemented in mock-backend.js). Only needed for the fallback approach.
|
||||
- `useAppIdentity.ts`: `isIdentityAwareApp('indeedhub')` is true → on iframe
|
||||
load with no stored identity it opens the identity-picker modal. In the demo
|
||||
this is a blocking modal the visitor shouldn't see.
|
||||
- `appLauncher.ts openSession`: `IS_DEMO && isDemoExternal(appId)` is the only
|
||||
thing forcing indeedhub external; `NEW_TAB_APP_IDS` is already bypassed when
|
||||
`IS_DEMO && isDemoApp(appId)`. `AppSession.vue mustOpenNewTab` has the same
|
||||
two-clause shape. Removing indeedhub from `DEMO_EXTERNAL_URLS` while keeping
|
||||
`isDemoApp('indeedhub')` true flips it to the iframe path everywhere.
|
||||
- `mock-backend.js`: per-visitor session state is initialized via
|
||||
`md['package-data'] = structuredClone(staticDevApps)` (~line 5493), so
|
||||
adding an entry to `staticDevApps` (~line 828) makes it installed on every
|
||||
fresh session. `APP_PORTS`-style map at ~line 323 already has
|
||||
`'indeedhub': 8190`; an icon exists at `/assets/img/app-icons/indeedhub.png`.
|
||||
- `Dockerfile.web` copies `nginx-demo.conf` to `/etc/nginx/nginx.conf.template`
|
||||
and runs `docker-entrypoint-custom.sh` (env substitution) — read the
|
||||
entrypoint before editing so the new server block's nginx `$vars` survive
|
||||
templating the same way the existing blocks' do.
|
||||
- Both Docker builds already scrub + fail on any occurrence of the private
|
||||
release-server IP; nothing in this change may hardcode host IPs — build the
|
||||
iframe URL from `window.location.hostname`.
|
||||
- `indeedhub/` at repo root is a git submodule (not checked out) — NEVER stage
|
||||
any path under it. `indeedhub-demo/` is a prior standalone-build attempt
|
||||
(clones the GitHub fork, builds with VITE env); this plan supersedes it by
|
||||
proxying the LIVE site instead — leave that directory untouched.
|
||||
</verified_findings>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: nginx whole-origin proxy on :2101 + sign-in seed script + compose ports</name>
|
||||
<files>neode-ui/docker/nginx-demo.conf, neode-ui/docker/indee-demo-signin.js, neode-ui/Dockerfile.web, docker-compose.demo.yml, demo-deploy/docker-compose.yml, neode-ui/docker/docker-entrypoint.sh</files>
|
||||
<action>
|
||||
Add a second `server` block to nginx-demo.conf: `listen 2101;` that is a
|
||||
pure whole-origin reverse proxy of `https://indee.tx1138.com` — no path
|
||||
prefix, no URL rewriting (this is the fix for the documented sub_filter
|
||||
breakage: the SPA keeps its own absolute-root paths). In that block:
|
||||
`location / { proxy_pass https://indee.tx1138.com; }` with
|
||||
`proxy_ssl_server_name on;`, `proxy_ssl_name indee.tx1138.com;`,
|
||||
`proxy_set_header Host indee.tx1138.com;`,
|
||||
`proxy_http_version 1.1;` + WebSocket upgrade headers (reuse the existing
|
||||
`$connection_upgrade` map), `proxy_hide_header X-Frame-Options;` and
|
||||
`proxy_hide_header Content-Security-Policy;`. For HTML injection:
|
||||
`proxy_set_header Accept-Encoding "";` (upstream must not gzip or
|
||||
sub_filter no-ops), `sub_filter_types text/html;`, `sub_filter_once on;`,
|
||||
`sub_filter '</head>' '<script src="/__demo/indee-demo-signin.js"></script></head>';`
|
||||
— a classic (non-module) script injected at end of head still executes
|
||||
BEFORE the SPA's deferred module bundle, which is what the seeding needs.
|
||||
Add `location = /__demo/indee-demo-signin.js { root /usr/share/nginx/html; }`
|
||||
(or alias) inside the 2101 server so the seed script is served same-origin
|
||||
to the iframe. Update the comment block that currently explains why
|
||||
IndeeHub is not proxied (lines ~106-109) to describe the new :2101 design.
|
||||
|
||||
Create neode-ui/docker/indee-demo-signin.js: a small plain-JS classic
|
||||
script, clearly headed with a comment stating it is PUBLIC-DEMO-ONLY and
|
||||
that the embedded key is a freshly generated THROWAWAY demo identity, not
|
||||
a real secret. Generate ONE fresh secp256k1 keypair at implementation time
|
||||
(e.g. `node -e` with a tiny script using any available schnorr/secp lib, or
|
||||
a one-off `npx` of nostr-tools in the scratchpad — the generator itself is
|
||||
not committed) and embed hex sk + hex pk as constants. The script: if
|
||||
`localStorage.getItem('indeedhub-accounts')` is empty/absent, write the
|
||||
two keys IndeeHub's boot-restore reads — `indeedhub-accounts` (JSON array
|
||||
with ONE serialized private-key account: verify the exact `type` string
|
||||
and common-field shape against the live bundle per verified_findings, shape
|
||||
`{ id, type, pubkey, signer: { key } }` + whatever `loadCommonFields`
|
||||
round-trips, give it a friendly name/metadata like "Archy Demo" if the
|
||||
shape supports it) and `indeedhub-active-account` (that account's id).
|
||||
Because the script runs on the :2101 origin inside the iframe, this
|
||||
touches only the proxied app's isolated storage. IndeeHub then restores
|
||||
the account on boot and self-signs with its own bundled signer — no
|
||||
window.nostr and no parent bridge required. Do NOT define a partial
|
||||
`window.nostr` in this approach (a pubkey-only shim with a broken
|
||||
signEvent causes worse failures than no shim).
|
||||
|
||||
FALLBACK (only if live testing in Task-3 verification shows the seeded
|
||||
account shape is not accepted): seed an `"extension"`-type account
|
||||
instead, define a `window.nostr` postMessage client in this same script
|
||||
(request/response protocol matching useNostrBridge: post
|
||||
`{type:'nostr-request', id, method, params}` to `window.parent`, resolve on
|
||||
`{type:'nostr-response', id, ...}`), and implement `node.nostr-sign` /
|
||||
`identity.nostr-sign` in mock-backend.js with real schnorr signatures over
|
||||
the same throwaway key (add `nostr-tools` to neode-ui dependencies — it is
|
||||
pure JS and Dockerfile.backend runs `npm install` over package.json).
|
||||
Prefer the primary approach; only fall back with evidence.
|
||||
|
||||
Wire the plumbing: `EXPOSE 2101` in Dockerfile.web (the seed script is
|
||||
already inside `neode-ui/` so the existing `COPY neode-ui/ ./` +
|
||||
dist copy do NOT ship it — add an explicit
|
||||
`COPY neode-ui/docker/indee-demo-signin.js /usr/share/nginx/html/__demo/indee-demo-signin.js`
|
||||
in the nginx stage of Dockerfile.web; it lands only in the demo web image,
|
||||
never in real-node artifacts). Publish the port in docker-compose.demo.yml
|
||||
(`"2101:2101"` on neode-web) and demo-deploy/docker-compose.yml (use an
|
||||
env-overridable mapping consistent with its existing `DEMO_WEB_PORT`
|
||||
style, e.g. `"${DEMO_INDEE_PORT:-2101}:2101"`, and document it in that
|
||||
file's header comment). Read docker-entrypoint.sh first and make sure the
|
||||
new server block survives its template substitution exactly like the
|
||||
existing blocks (same escaping convention for nginx `$` variables); touch
|
||||
the entrypoint only if its substitution list needs it.
|
||||
|
||||
Do not put any host IP in any of these files; upstream hostname
|
||||
indee.tx1138.com is fine.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>docker run --rm -v "$PWD/neode-ui/docker/nginx-demo.conf:/etc/nginx/nginx.conf:ro" nginx:alpine nginx -t (or, if docker unavailable locally, `nginx -t -c` via a podman run — config must parse). Plus: grep -c "2101" neode-ui/docker/nginx-demo.conf docker-compose.demo.yml demo-deploy/docker-compose.yml neode-ui/Dockerfile.web — each ≥1; grep -q "indee-demo-signin" neode-ui/docker/nginx-demo.conf && grep -qi "throwaway" neode-ui/docker/indee-demo-signin.js</automated>
|
||||
</verify>
|
||||
<done>nginx config parses with the new :2101 whole-origin proxy block (framing headers stripped, sub_filter injection, WS upgrade); seed script exists with labelled throwaway demo key and idempotent localStorage seeding; both compose files publish 2101; demo web image copies the script and exposes the port; no host IPs added anywhere.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: demo frontend — iframe launch via :2101 and no identity-picker wall</name>
|
||||
<files>neode-ui/src/composables/useDemoIntro.ts, neode-ui/src/views/appSession/useAppIdentity.ts</files>
|
||||
<action>
|
||||
In useDemoIntro.ts: remove `indeedhub` from `DEMO_EXTERNAL_URLS` (delete
|
||||
the map entirely if it becomes empty, simplifying `isDemoExternal` to
|
||||
return false — keep the exported function so call sites in appLauncher.ts
|
||||
and AppSession.vue compile unchanged). Make `demoAppUrl('indeedhub')`
|
||||
return the proxied origin built at runtime:
|
||||
`${window.location.protocol}//${window.location.hostname}:2101/`
|
||||
(hostname, never a hardcoded host/IP — works on any deploy host). Keep
|
||||
`isDemoApp('indeedhub')` true (it must stay in the demoable set so the
|
||||
NEW_TAB bypass in appLauncher.openSession and AppSession.mustOpenNewTab
|
||||
keeps routing it into the in-app iframe session, and so the install
|
||||
button stays enabled). Update the file-header comment block that
|
||||
currently documents the external-tab workaround to describe the :2101
|
||||
whole-origin proxy design instead. SSR-safety is not a concern (Vite SPA)
|
||||
but guard `typeof window !== 'undefined'` if other tests import the module
|
||||
in node context — check the existing unit tests under
|
||||
src/views/appSession/__tests__/ and src/stores/__tests__/ for assertions
|
||||
about indeedhub being demo-external and update them to the new behavior.
|
||||
|
||||
In useAppIdentity.ts: gate the picker for the demo. Import IS_DEMO from
|
||||
useDemoIntro and in `onIframeLoadIdentity` / `handleIdentityRequest`,
|
||||
when IS_DEMO is true, never set `showIdentityPicker` — the demo visitor
|
||||
must not be interrupted by an identity modal (the embedded IndeeHub is
|
||||
already signed in via the seeded account from Task 1, and `sendIdentity`'s
|
||||
`identity.sign` RPC is not what logs it in). Real-node behavior
|
||||
(picker on first launch) is untouched because IS_DEMO is compile-time
|
||||
false there.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && npx vitest run src/views/appSession src/stores --silent 2>&1 | tail -5 (all green) && VITE_DEMO=1 npm run build && grep -rq "2101" dist/assets && npm run build && grep -rq "indee.tx1138.com" dist/assets && echo BUNDLE-OK</automated>
|
||||
</verify>
|
||||
<done>Demo build (VITE_DEMO=1) bundle contains the :2101 launch logic (grep hit proves the build didn't silently no-op — per CLAUDE.md); plain build still compiles and demo-gated branches do not alter non-demo behavior; unit tests updated and green; launching indeedhub in demo resolves to the same-host :2101 origin in the iframe session; identity picker suppressed only under IS_DEMO.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: mock backend — IndeeHub pre-installed on fresh demo sessions</name>
|
||||
<files>neode-ui/mock-backend.js</files>
|
||||
<action>
|
||||
Add an `indeedhub` entry to `staticDevApps` in mock-backend.js using the
|
||||
existing `staticApp({...})` helper: id `indeedhub`, title `Indeehub`
|
||||
(match the existing title map at ~line 537 and APP_TITLES), a short/long
|
||||
description consistent with the marketplace copy ("Bitcoin documentary
|
||||
streaming platform" per the existing entry), `state: 'running'`,
|
||||
`lanPort: 8190` (matches the existing port map), icon
|
||||
`/assets/img/app-icons/indeedhub.png`. Because per-session demo state is
|
||||
`structuredClone(staticDevApps)`, this alone makes it installed+running on
|
||||
every fresh session. Then reconcile the rest of the mock so nothing
|
||||
contradicts installed status: check the marketplace/available-apps mock
|
||||
responses and any install/uninstall handlers (~lines 540-740, 1900-1960,
|
||||
4900+) for `indeedhub` entries that would render it as not-installed or
|
||||
double-listed, and check `DEMO_APP_PAGES` does NOT grow an indeedhub
|
||||
placeholder (the demo launch URL bypasses /app/indeedhub/ entirely — the
|
||||
iframe goes to the :2101 origin). Keep the existing `node.nostr-pubkey`
|
||||
mock as-is unless Task 1's fallback path was taken (in which case align
|
||||
its pubkey with the throwaway demo key and add the sign handlers described
|
||||
there).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && node -e "const s=require('fs').readFileSync('mock-backend.js','utf8'); if(!/staticDevApps[\s\S]*?indeedhub:\s*staticApp/.test(s)) process.exit(1)" && (DEMO=1 timeout 20 node mock-backend.js & sleep 4; curl -s -X POST localhost:5959/rpc/v1 -H 'content-type: application/json' -d '{"method":"server.data","id":1}' -H 'cookie: demo=fresh' | grep -o '"indeedhub"' | head -1; kill %1 2>/dev/null) — expect an indeedhub hit in fresh-session package-data (adapt the RPC method/auth to what the mock actually serves; a login with the demo password first is fine)</automated>
|
||||
</verify>
|
||||
<done>A fresh demo session's package-data includes indeedhub as installed and running with launchable UI; My Apps shows it without an install step; no duplicate/contradictory indeedhub listing in marketplace mocks; mock backend boots cleanly with DEMO=1.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| demo nginx :2101 → indee.tx1138.com | demo host proxies an external site; upstream content is served under the demo host |
|
||||
| iframe (:2101 origin) ↔ parent (:2100 origin) | cross-origin; parent NIP-07 bridge only used in fallback path |
|
||||
| public visitors → demo host | anyone can drive the proxy |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-gjd-01 | Spoofing | throwaway demo nostr key | low | accept | key is a labelled public demo identity by design; generated fresh, never a real user key; anyone extracting it can only impersonate "the demo visitor" |
|
||||
| T-gjd-02 | Info disclosure | private release-server IP in served content | high | mitigate | no host IPs added in any changed file; iframe URL derived from window.location.hostname; existing Docker-build scrub+fail guards remain the backstop |
|
||||
| T-gjd-03 | Tampering | open reverse proxy on :2101 | medium | mitigate | proxy is pinned to a single upstream host (proxy_pass fixed hostname + proxy_ssl_name), no dynamic upstreams, no request-driven destinations — it cannot be used as an open proxy |
|
||||
| T-gjd-04 | Elevation | header stripping (X-Frame-Options/CSP) | low | accept | stripping applies only to the :2101 demo proxy of one known site, demo image only; real-node builds never carry this config |
|
||||
| T-gjd-SC | Tampering | npm installs | low | accept | primary path adds no dependencies; fallback path adds only nostr-tools (well-known, verify on npmjs.com before install) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Local (executor, before commit):
|
||||
1. nginx config parses (Task 1 verify).
|
||||
2. Unit tests green; VITE_DEMO=1 build contains ":2101" logic; plain build
|
||||
unaffected (Task 2 verify). Note: demo-gated strings are tree-shaken out of
|
||||
the plain build — that is EXPECTED; the bundle-grep for demo strings must be
|
||||
done on the VITE_DEMO=1 build, which is exactly what the demo Docker image
|
||||
builds (Dockerfile.web defaults ARG VITE_DEMO=1).
|
||||
3. Fresh-session mock package-data includes indeedhub (Task 3 verify).
|
||||
4. Optional full-stack smoke: `docker compose -f docker-compose.demo.yml up
|
||||
--build` locally, browse http://localhost:2100 in a private window →
|
||||
login `entertoexit` → IndeeHub installed → launch → iframe renders the
|
||||
proxied site from http://localhost:2101 with a signed-in account.
|
||||
5. `git status` — confirm nothing under indeedhub/ is staged, ever.
|
||||
|
||||
Post-deploy on vps2 (orchestrator deploys; verify on http://146.59.87.168:2100):
|
||||
1. `curl -sI http://146.59.87.168:2101/` returns 200 with NO X-Frame-Options
|
||||
header and the injected `indee-demo-signin.js` tag in the HTML body
|
||||
(`curl -s http://146.59.87.168:2101/ | grep indee-demo-signin`). If the
|
||||
port is unreachable, the vps2 firewall needs 2101 opened — flag to
|
||||
orchestrator.
|
||||
2. Fresh private browser window → :2100 → login → IndeeHub shows installed/
|
||||
running on the dashboard/My Apps without any install action.
|
||||
3. Launch IndeeHub → renders inside the in-app iframe (panel/overlay), not a
|
||||
new tab; content browsable; no identity-picker modal.
|
||||
4. Signed-in check: IndeeHub header shows an active account (avatar/profile
|
||||
instead of a sign-in button). If the seeded account shape was rejected
|
||||
(login wall still visible), execute the documented fallback (extension
|
||||
account + window.nostr shim + mock signer) and redeploy.
|
||||
5. View-source/network spot-check: no occurrence of the private
|
||||
release-server IP in any served response.
|
||||
6. Repeat-visit check: reload the iframe once — a service worker registered by
|
||||
IndeeHub may serve cached HTML without the injected tag on later loads;
|
||||
that is acceptable because localStorage is already seeded on first load,
|
||||
but confirm sign-in persists.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Demo visitor on a fresh browser sees IndeeHub installed, launches it into
|
||||
the in-app iframe, and browses indee.tx1138.com content signed in — zero
|
||||
clicks spent on install/login/identity modals.
|
||||
- Real-node build behavior unchanged (all changes IS_DEMO- or demo-image-gated).
|
||||
- No secrets committed beyond the labelled throwaway demo key; nothing staged
|
||||
under indeedhub/; demo serves no private release-server IP.
|
||||
- Work committed in focused commits (infra / frontend / mock) with the
|
||||
Co-Authored-By trailer and pushed via gitea-ai per CLAUDE.md; docs left to
|
||||
the orchestrator.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260729-gjd-demo-make-indee-tx1138-com-work-in-the-a/260729-gjd-SUMMARY.md` when done.
|
||||
</output>
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
---
|
||||
phase: quick-260729-gjd
|
||||
plan: 01
|
||||
subsystem: public-demo
|
||||
tags: [demo, indeedhub, nginx, reverse-proxy, nostr, mock-backend]
|
||||
requires: []
|
||||
provides:
|
||||
- "IndeeHub whole-origin demo proxy on :2101 (framing headers stripped, sign-in seeded)"
|
||||
- "Demo iframe launch of indeedhub via demoAppUrl → <host>:2101/"
|
||||
- "IndeeHub pre-installed/running on every fresh demo session"
|
||||
affects: [demo-deploy, neode-ui demo image]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Whole-origin per-port reverse proxy for frame-busting external SPAs (vs broken path-prefix sub_filter)"
|
||||
- "localStorage seeding via injected classic script on the proxied origin (applesauce-accounts nsec account)"
|
||||
key-files:
|
||||
created:
|
||||
- neode-ui/docker/indee-demo-signin.js
|
||||
modified:
|
||||
- neode-ui/docker/nginx-demo.conf
|
||||
- neode-ui/Dockerfile.web
|
||||
- docker-compose.demo.yml
|
||||
- demo-deploy/docker-compose.yml
|
||||
- neode-ui/src/composables/useDemoIntro.ts
|
||||
- neode-ui/src/views/appSession/useAppIdentity.ts
|
||||
- neode-ui/mock-backend.js
|
||||
decisions:
|
||||
- "Primary sign-in path used (seeded nsec account, self-signing) — NIP-07 bridge fallback NOT needed; verified against the live bundle"
|
||||
- "Dropped `sub_filter_types text/html` (text/html is nginx's default sub_filter type; explicit listing produced a duplicate-MIME warning)"
|
||||
metrics:
|
||||
duration: "~50 min"
|
||||
completed: 2026-07-29
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick Task 260729-gjd: IndeeHub in the Demo Summary
|
||||
|
||||
**One-liner:** Whole-origin nginx proxy of indee.tx1138.com on :2101 with an injected throwaway-nsec sign-in seeder, demo iframe launch via same-host :2101, and IndeeHub pre-installed in every fresh mock-backend session.
|
||||
|
||||
## Commits
|
||||
|
||||
| Task | Commit | Scope |
|
||||
|------|--------|-------|
|
||||
| 1 | 69bc3d3f | nginx :2101 whole-origin proxy + indee-demo-signin.js seeder + Dockerfile.web COPY/EXPOSE + both compose files publish 2101 |
|
||||
| 2 | 66d540f8 | useDemoIntro: DEMO_EXTERNAL_URLS → DEMO_PROXY_PORTS, demoAppUrl builds `<protocol>//<hostname>:2101/`; useAppIdentity: picker suppressed under IS_DEMO |
|
||||
| 3 | d00ca624 | mock-backend.js staticDevApps gains indeedhub (running, lanPort 8190) → installed on every fresh session |
|
||||
|
||||
## What was verified at exec time (live-bundle facts)
|
||||
|
||||
- Live site still serves `X-Frame-Options: SAMEORIGIN`, no CSP; bundle `assets/index-BMWtjRCn.js`.
|
||||
- Account serialization confirmed by de-minifying the live bundle: private-key account class has `static type="nsec"`, `toJSON` → `{ signer: { key: <hex sk> }, id, pubkey, metadata, type }`; the manager registers the nsec type (`MM(Fe)` registers `mr`) and restores from `indeedhub-accounts` + activates by id from `indeedhub-active-account`. `Vn`/`je` confirmed hex decode/encode.
|
||||
- Pubkey math independently validated against BIP340 test vectors (sk=1 → Gx, sk=3 → F9308A01…) before embedding the generated pair. Mismatch would trigger the bundle's "Account signer mismatch" guard, so this was load-bearing.
|
||||
|
||||
## Throwaway demo identity
|
||||
|
||||
Freshly generated 2026-07-29 for this task (generator ran in scratchpad, not committed):
|
||||
- pk `7261540160244ec65ce0bf86ba03997e9b1b3b35c277e416bf1c7ba4271fee31`
|
||||
- sk embedded in `neode-ui/docker/indee-demo-signin.js`, clearly labelled PUBLIC-DEMO-ONLY / not a secret (threat T-gjd-01: accepted by design). Never a real user key.
|
||||
|
||||
## Local verification results
|
||||
|
||||
1. **nginx parse:** `nginx -t` clean in `nginx:alpine` (podman, with `--add-host neode-backend:127.0.0.1` to satisfy the pre-existing upstream reference).
|
||||
2. **Live proxy smoke (podman, config + seeder mounted):** `curl` through :2101 → 200, **no X-Frame-Options / CSP**, injected `<script src="/__demo/indee-demo-signin.js">` present in HTML, seed script served same-origin, hashed asset `/assets/index-BMWtjRCn.js` proxied 200.
|
||||
3. **Unit tests:** 195/195 green (`src/views/appSession` + `src/stores`), running with IS_DEMO=false — non-demo path exercised.
|
||||
4. **Demo build:** `VITE_DEMO=1 npm run build` succeeded; bundle (`web/dist/neode-ui/assets/index-ChDwfLt5.js`) contains the 2101 launch logic. This is exactly what Dockerfile.web builds (ARG VITE_DEMO=1 default).
|
||||
5. **Mock backend:** boots with DEMO=1; `/ws/db` initial dump of a fresh session contains `indeedhub` with `state=running`, ui=true, lan `http://localhost:8190`.
|
||||
6. **No IP leaks:** none of the changed files contain the private release-server IP; pre-existing occurrences in dist (catalog.json/marketplace data) are scrubbed+gated by the existing Dockerfile.web guard.
|
||||
7. **Submodule guard:** ran before all three commits; nothing under `indeedhub/` ever staged. No file deletions in any commit.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
**1. [Minor] Dropped `sub_filter_types text/html` from the :2101 block**
|
||||
- **Found during:** Task 1 nginx parse check
|
||||
- **Issue:** nginx warns `duplicate MIME type "text/html"` — text/html is sub_filter's built-in default type
|
||||
- **Fix:** removed the redundant directive (identical behavior), noted in a comment
|
||||
- **Commit:** 69bc3d3f
|
||||
|
||||
**2. [Environment] Plain (non-demo) build + vue-tsc typecheck could not be run — permission denied**
|
||||
- Three attempts (`npm run build`, `vite build --outDir <scratch>`, `vue-tsc --noEmit`) were denied by the permission system mid-execution.
|
||||
- **Proxy coverage:** 195 unit tests ran and passed under IS_DEMO=false (compiles + non-demo runtime behavior), and the demo build compiled the same source. All changed TS is IS_DEMO-gated with types unchanged at call sites.
|
||||
- **Residual risk:** low; a plain `npm run build` before the next real-node frontend ship will confirm (it runs vue-tsc).
|
||||
|
||||
**3. [Flag] `web/dist/neode-ui/` currently holds a VITE_DEMO=1 bundle**
|
||||
- The demo verification build overwrote the (gitignored) `web/dist/neode-ui` output. **Rebuild with a plain `npm run build` before any real-node frontend deploy/ISO/OTA that rsyncs `web/dist`** — do not ship the demo bundle to real nodes.
|
||||
|
||||
## Fallback status
|
||||
|
||||
The plan's fallback (extension-type account + window.nostr postMessage shim + mock signer) was **not needed** — the primary seeded-nsec path matches the live bundle's restore contract exactly. If post-deploy testing shows a login wall anyway, the fallback is fully documented in the PLAN (Task 1 action block).
|
||||
|
||||
## Post-deploy checklist for orchestrator (vps2, after demo image rebuild + redeploy)
|
||||
|
||||
1. `curl -sI http://146.59.87.168:2101/` → 200, NO `X-Frame-Options`; `curl -s http://146.59.87.168:2101/ | grep indee-demo-signin` → hit. **If unreachable: open port 2101 in the vps2 firewall** (new requirement of this change).
|
||||
2. Fresh private window → `http://146.59.87.168:2100` → login `entertoexit` → IndeeHub shows installed/running in My Apps with no install step.
|
||||
3. Launch IndeeHub → renders inside the in-app iframe session (not a new tab), content browsable, **no identity-picker modal**.
|
||||
4. Signed-in check: IndeeHub header shows an active account (avatar/profile, not a sign-in button). Note: the throwaway key has no published kind-0 profile, so expect a default avatar/truncated npub rather than a named profile — that still counts as signed in. If a login wall appears, execute the documented fallback and redeploy.
|
||||
5. Spot-check served responses for the private release-server IP (should be none; build guard enforces).
|
||||
6. Reload the iframe once — sign-in must persist (localStorage already seeded even if a service worker serves cached HTML without the injected tag).
|
||||
7. Reminder: the `demo-deploy` thin stack now publishes `${DEMO_INDEE_PORT:-2101}:2101` — the public archy-demo repo copy of that compose file needs syncing when the images ship.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- neode-ui/docker/indee-demo-signin.js — FOUND
|
||||
- neode-ui/docker/nginx-demo.conf :2101 block — FOUND
|
||||
- Commits 69bc3d3f, 66d540f8, d00ca624 — FOUND in git log
|
||||
- No paths under indeedhub/ in any commit — VERIFIED
|
||||
- SUMMARY frontmatter status: complete — SET
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
---
|
||||
phase: quick-260729-hj1
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- neode-ui/mock-backend.js
|
||||
- neode-ui/src/views/PeerFiles.vue
|
||||
- demo/content/music/ (2 new Wavlake mp3s)
|
||||
- demo/peer-media/ (Wavlake artwork + replaced photo-*.jpg files)
|
||||
autonomous: true
|
||||
requirements: [A1, A2, A3, A4, A5, A6, A7, A8]
|
||||
user_setup: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Searching the Cloud files search for the Wavlake track titles returns them as peer-file results from at least one demo peer (A1, A2)"
|
||||
- "The first Wavlake track is PAID; buying it via the demo ecash flow immediately autoplays it in the bottom GlobalAudioPlayer bar (A3)"
|
||||
- "Cloud -> Paid Files tab shows pre-seeded purchase-history entries (sats paid + date); clicking an audio purchase plays it in the bottom bar (A4)"
|
||||
- "The aggregated Peer Files tab shows each library item on ~1 peer, with only 2-3 items deliberately duplicated across peers (A5)"
|
||||
- "Photo peer files show real photographs as card previews and in the full-screen viewer, not picsum placeholders (A6)"
|
||||
- "In the PeerFiles gallery, clicking a FREE image opens the full-screen image viewer (A8); free audio -> bottom player; free video -> video modal; paid unowned items stay gated behind the pay modal (A7)"
|
||||
- "Real-node build unaffected: all existing unit tests stay green, npm run build succeeds, and the frontend changes degrade gracefully when the real backend omits demo-only fields"
|
||||
artifacts:
|
||||
- "demo/content/music/: two downloaded Wavlake mp3s (real bytes, committed)"
|
||||
- "demo/peer-media/: Wavlake artwork jpg(s) + photo-*.jpg replaced with real photographs"
|
||||
- "neode-ui/mock-backend.js: Wavlake PEER_LIBRARY entries, deduped peerCatalogFor, seeded+persistent owned-content state, real-bytes paid/owned downloads, /api/peer-content streaming route"
|
||||
- "neode-ui/src/views/PeerFiles.vue: click-to-open viewer routing incl. free-image lightbox fix, unified post-payment in-app open/autoplay"
|
||||
key_links:
|
||||
- "content.download-peer-paid must return real bytes + correct mime_type -> confirmEcashPay's audio branch -> audioPlayer.play (this is the A3 autoplay chain; today the mock returns text/plain which breaks it)"
|
||||
- "new /api/peer-content/:onion/:id mock route -> free-audio streaming AND the free-image viewer src (frontend already builds this URL at PeerFiles.vue:1011/1470; it 404s in the demo today)"
|
||||
- "seeded content.owned-list entries must reference onions of the SAME session's demoFederationNodes() output and items actually present in that peer's peerCatalogFor slice, or Owned badges will not line up"
|
||||
- "mock content.owned-list must include session purchases, otherwise loadOwned() (called after every purchase at PeerFiles.vue:1314) wipes the just-bought Owned state"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Peer-files media batch for the public demo plus two shared-behavior fixes.
|
||||
|
||||
Demo/mock only (A1-A6): add two real Wavlake tracks (metadata + bytes fetched at
|
||||
execution time), make the first one PAID with a working pay -> autoplay-in-bottom-bar
|
||||
flow, seed visible purchase history, dedupe the peer catalog to ~2-3 intentional
|
||||
duplicates, and replace picsum placeholder photos with real photographs.
|
||||
|
||||
Both builds (A7, A8): clicking a peer file opens the appropriate viewer (photos ->
|
||||
full-screen image viewer, audio -> bottom music player, video -> video modal), and
|
||||
fix the confirmed bug that FREE images are a click no-op in the PeerFiles gallery
|
||||
(PeerFiles.vue line 91 ternary falls through to `undefined` for non-playable free items).
|
||||
|
||||
Purpose: the peer-files demo is a flagship "buy content over the mesh" showcase; today
|
||||
paid purchases unlock a text placeholder, free images don't open, free streaming 404s,
|
||||
and the catalog is visibly duplicate-heavy.
|
||||
Output: updated mock-backend.js + committed demo media assets + PeerFiles.vue behavior
|
||||
fixes, tests green, built bundle verified.
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
|
||||
## Verified recon (do not re-derive; spot-check only)
|
||||
|
||||
**Wavlake API (probed 2026-07-29, works from this network):**
|
||||
- Track 1 (user link `wavlake.com/track/3504d80b-...`):
|
||||
`https://catalog.wavlake.com/v1/tracks/3504d80b-b4bf-4196-923b-7ed8b60caec9`
|
||||
returns `data.title = "WEBFIVEFOURTHREETWOONE"`, `data.artist = "Zazawowow"`,
|
||||
`data.albumTitle = "WEBFIVE"`, `data.duration = 249`,
|
||||
`data.artworkUrl = https://d12wklypp119aj.cloudfront.net/image/ed6c75e5-e469-4f52-b073-a18b237dadae.jpg`,
|
||||
and a stream URL whose direct CDN form is
|
||||
`https://d12wklypp119aj.cloudfront.net/track/3504d80b-b4bf-4196-923b-7ed8b60caec9.mp3`
|
||||
(HTTP 200, `content-type: audio/mpeg`, `content-length: 6041903`, accept-ranges,
|
||||
NO signature/expiry params — stable).
|
||||
- Album (user link `wavlake.com/album/9be3cdce-...`):
|
||||
`https://catalog.wavlake.com/v1/albums/9be3cdce-015e-4e7e-8ffe-c017945465c4`
|
||||
returns title "Michael Michael Saylor" (single) by Zazawowow with one track id
|
||||
`ba80e385-62a5-4309-ac1e-bb0493b8539f`. Fetch that id from the tracks endpoint for
|
||||
its metadata; its mp3 follows the same CDN pattern
|
||||
(`https://d12wklypp119aj.cloudfront.net/track/ba80e385-62a5-4309-ac1e-bb0493b8539f.mp3`).
|
||||
- Hotlink-vs-download criterion (decided): DOWNLOAD the mp3s + artwork into the repo.
|
||||
The CDN URLs are stable, but the mock serves content as base64 bytes / disk files
|
||||
(`content.download-peer`, `content.preview-peer` read `entry.disk` with
|
||||
`fsSync.readFileSync`), so committed local files are the architecturally consistent
|
||||
choice and remove any CORS/expiry/offline risk. This matches the existing pattern —
|
||||
`demo/content/music/*.mp3` and `demo/peer-media/*.jpg` are already committed real files.
|
||||
External wavlake/CDN URLs would be allowed under the demo IP-leak rule (only
|
||||
146.59.87.168 must never appear), but are simply not needed.
|
||||
|
||||
**Mock backend (`neode-ui/mock-backend.js`, port 5959, RPC at `/rpc/v1`):**
|
||||
- `PEER_LIBRARY` at line ~1256: films/series/books paid (no disk bytes), music/photos/
|
||||
docs free with `disk:` pointing at committed files under `demo/content/` and
|
||||
`demo/peer-media/` (helpers `PEER_MEDIA`/`PEER_CONTENT` at lines 1254-1255).
|
||||
- `peerCatalogFor(onion)` line ~1321: hash-based slice; the
|
||||
`((seed ^ (i * 2654435761)) >>> 0) % 12 < 3` clause puts each item on ~25% of the
|
||||
12 peers (`demoFederationNodes()` line ~1339, onions are RANDOM per session) —
|
||||
this is the heavy-duplication source (A5).
|
||||
- `content.preview-peer` (line ~2358): serves `entry.preview` or the image's `disk`.
|
||||
- `content.download-peer` (line ~2372): serves `entry.disk` bytes, else text placeholder.
|
||||
- `content.owned-list` (line ~2402): returns `{ items: [] }` — no purchase history, and
|
||||
it CLOBBERS just-bought state (see key_links).
|
||||
- `content.owned-get` / `content.download-peer-paid` / `-invoice` / `-onchain`
|
||||
(lines ~2405-2425): all return a TEXT PLACEHOLDER (`mime_type: text/plain`) — this is
|
||||
why paid autoplay (A3) cannot work today. Ecash rail deduction logic here must be kept.
|
||||
- There is NO `/api/peer-content/:onion/:id` route — the frontend's free-item stream
|
||||
URL (PeerFiles.vue lines 1011 and 1470) 404s in the demo.
|
||||
|
||||
**Frontend (`neode-ui/src/views/PeerFiles.vue`, 1616 lines):**
|
||||
- Card click (line 91): `isOwned ? viewOwned : (isPlayable ? playMedia : undefined)` —
|
||||
free images fall to `undefined` = the A8 bug. `isPlayable` (line 963) is video/audio only.
|
||||
- `viewOwned` (line 702): audio -> `audioPlayer.play` (bottom bar), image/video -> the
|
||||
Teleport-to-body "Owned-content viewer" modal (template line 310, footer hardcodes
|
||||
"Owned · unlocked" at line 354).
|
||||
- `confirmEcashPay` (line 1277): on success marks owned, audio -> `audioPlayer.play`
|
||||
(A3 autoplay ALREADY implemented here — only the mock's text/plain response breaks it),
|
||||
then `void loadOwned()` (line 1314).
|
||||
- `payWithLightning` (line 1364), `pollInvoice` (line 1417), `pollOnchain` (line 1197):
|
||||
on success these call `triggerDownload` (browser download) instead of the in-app
|
||||
open/autoplay path — inconsistent with confirmEcashPay.
|
||||
- `playMedia` (line 1460): free audio -> `audioPlayer.play(streamUrl)`, free video ->
|
||||
video modal via the same `/api/peer-content/` stream URL; paid uses
|
||||
`content.preview-peer` bytes ("10% preview"). NOTE: for a PAID AUDIO item the
|
||||
pre-purchase Preview button plays whatever `content.preview-peer` returns — so for the
|
||||
paid Wavlake track the mock MUST return audio bytes there, not artwork.
|
||||
- Preview thumbnails (watcher line 872): fetched ONLY for image/video mimes; audio cards
|
||||
show a waveform icon. The real backend's `content.preview-peer`
|
||||
(core/archipelago/src/api/rpc/content.rs:1113) proxies the seller's
|
||||
`/content/{id}/preview` — for audio that is audio bytes, so do NOT extend the
|
||||
thumbnail watcher to audio (it would fetch audio blobs as "thumbnails" on real nodes).
|
||||
- `Cloud.vue`: Paid Files tab (line ~155) lists `content.owned-list` items
|
||||
(`PaidItem { onion, content_id, filename, mime_type, size_bytes, paid_sats,
|
||||
purchased_at }`) and `viewPaidItem` (line ~470) plays audio in the bottom bar — this
|
||||
is the A4 purchase-history surface, already built; it only needs seeded data.
|
||||
Cloud search (`runSearch` line ~880) filters the aggregated `peerFiles` by filename —
|
||||
Wavlake items are searchable as soon as they appear in any peer's catalog slice.
|
||||
- `GlobalAudioPlayer` is mounted in `App.vue` (line 48); `useAudioPlayer` is a global
|
||||
singleton — nothing to change there.
|
||||
- Tests: `src/views/__tests__/PeerFilesRefresh.test.ts` mounts PeerFiles.vue — keep green.
|
||||
|
||||
**Project rules that bind this work:**
|
||||
- Modals/lightboxes must Teleport to body with full-screen backdrop (the existing
|
||||
owned-content viewer already complies — reuse it).
|
||||
- Do not add new UI entry points (no new tabs/cards/nav); only change behavior of
|
||||
existing elements.
|
||||
- NEVER stage/commit anything under `indeedhub/` (git submodule). Stage explicitly by
|
||||
path (`git add <paths>`), never `git add -A`.
|
||||
- Never expose 146.59.87.168 in demo-served content.
|
||||
- Commit each task when it works, message trailer `Co-Authored-By: Claude ...`.
|
||||
Executor commits code only; docs/summary are committed by the orchestrator.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Demo dataset — Wavlake tracks, real photos, dedupe, purchases, real paid bytes, streaming route</name>
|
||||
<files>neode-ui/mock-backend.js, demo/content/music/ (2 new mp3s), demo/peer-media/ (artwork + replaced photo-*.jpg)</files>
|
||||
<action>
|
||||
All changes in this task are demo/mock-only (mock-backend.js is not part of the
|
||||
real-node build).
|
||||
|
||||
1. Fetch Wavlake assets at execution time (A1, A2). Use curl against the verified
|
||||
endpoints in context: resolve track 3504d80b-b4bf-4196-923b-7ed8b60caec9 and the
|
||||
album 9be3cdce-015e-4e7e-8ffe-c017945465c4's single track
|
||||
ba80e385-62a5-4309-ac1e-bb0493b8539f via catalog.wavlake.com/v1/tracks/{id}.
|
||||
Parse real title/artist/albumTitle/duration/artworkUrl from the JSON (jq or node -e)
|
||||
— do NOT hardcode metadata from this plan except the ids; the API is the source of
|
||||
truth. Download each track's CDN mp3 into demo/content/music/ (filename derived from
|
||||
real title, e.g. "Zazawowow - WEBFIVEFOURTHREETWOONE.mp3") and each artworkUrl jpg
|
||||
into demo/peer-media/. Sanity-check downloads with file(1): mp3s must be MPEG audio
|
||||
(~6MB expected for track 1), artwork must be JPEG. Abort and report if the API shape
|
||||
changed.
|
||||
|
||||
2. Add both tracks to PEER_LIBRARY (A1-A3). mime_type audio/mpeg, real size_bytes
|
||||
(actual file size), disk: PEER_CONTENT('music', <filename>), description carrying
|
||||
real artist/album/duration metadata (this makes artist searchable too). First track
|
||||
(WEBFIVEFOURTHREETWOONE): access paid with a price the demo ecash balance easily
|
||||
covers (e.g. 21 sats). Second track: access 'free'. For the PAID track, give it a
|
||||
preview behavior consistent with playMedia: content.preview-peer for audio entries
|
||||
must return AUDIO bytes (read entry.disk; serving the full mp3 is acceptable for the
|
||||
demo, or the first ~10% byte-slice to match the "10% preview" badge — mp3 frames
|
||||
tolerate truncation), NOT the artwork jpg — see the recon note on playMedia's paid
|
||||
path. Keep image entries' preview behavior unchanged.
|
||||
|
||||
3. Dedupe peerCatalogFor (A5). Replace the probabilistic
|
||||
`((seed ^ (i * 2654435761)) >>> 0) % 12 < 3` inclusion with a deterministic
|
||||
assignment: each PEER_LIBRARY item lives on exactly one peer (e.g. index-based
|
||||
slot), plus a small explicit POPULAR list of 2-3 item ids that additionally appear
|
||||
on 1-2 more peers (a little duplication is realistic). Keep the function signature
|
||||
and per-session determinism (same onion -> same slice within a session). Ensure both
|
||||
Wavlake tracks land on at least one trusted peer's slice so Cloud search finds them.
|
||||
|
||||
4. Real photos (A6). Replace all ten demo/peer-media/photo-*.jpg picsum placeholders
|
||||
with real photographs downloaded at execution time from a stable free-license source
|
||||
(Wikimedia Commons Special:FilePath URLs with a width parameter, e.g. ?width=1200,
|
||||
are reliable and hotlink-free once committed). Pick images that match each entry's
|
||||
existing description (aurora over fjord, mountain lake, neon city rain, desert
|
||||
dunes, forest mist, ocean cliff, northern road, autumn valley, harbor dawn, alpine
|
||||
ridge) or update the descriptions (including the "sourced via picsum.photos"
|
||||
credit text and the stale PEER_LIBRARY header comment) to match reality. Keep the
|
||||
photo-*.jpg filenames so PEER_MEDIA references don't change; update each entry's
|
||||
size_bytes to the new actual file size. Verify each file with file(1) is a real
|
||||
JPEG of reasonable resolution (>= ~1000px wide).
|
||||
|
||||
5. Owned/purchase state (A3, A4). Introduce a mock owned-content store (e.g.
|
||||
mockState.ownedContent array of { onion, content_id, filename, mime_type,
|
||||
size_bytes, paid_sats, purchased_at }). Lazily seed it on first access with 2-4
|
||||
plausible past purchases dated days-to-weeks ago, referencing onions from the
|
||||
CURRENT session's demoFederationNodes() output and content ids actually present in
|
||||
that peer's peerCatalogFor slice (onions are random per session — compute, don't
|
||||
hardcode). At least one seeded purchase must be an audio item backed by real disk
|
||||
bytes so the Paid Files tab click plays music. content.owned-list returns this
|
||||
store (all fields of Cloud.vue's PaidItem interface). Every successful purchase
|
||||
path (content.download-peer-paid, and the -invoice/-onchain and onchain/invoice
|
||||
status flows' download calls) appends an entry with the real price and
|
||||
purchased_at=now, so the Owned badge survives the post-purchase loadOwned()
|
||||
refresh and purchases show up in the Paid Files tab.
|
||||
|
||||
6. Real bytes for paid/owned downloads (A3). Rework the shared
|
||||
content.owned-get / content.download-peer-paid / -invoice / -onchain case: look up
|
||||
the PEER_LIBRARY entry by content_id; when entry.disk exists return the real file
|
||||
bytes with mime_type entry.mime_type; keep the text placeholder only as fallback
|
||||
for entries without disk bytes (the fictional 2GB films). Preserve the existing
|
||||
ecash rail-deduction logic exactly. This makes buying the Wavlake track deliver a
|
||||
real mp3 with mime_type audio/mpeg, which is what confirmEcashPay's existing audio
|
||||
branch needs to autoplay in the bottom bar.
|
||||
|
||||
7. Streaming route. Add an Express GET route /api/peer-content/:onion/:content_id
|
||||
to mock-backend.js that resolves the PEER_LIBRARY entry and serves entry.disk via
|
||||
res.sendFile (Express handles Range/206 automatically — the frontend probes with
|
||||
Range: bytes=0-0), 404 JSON { error } otherwise. This unbreaks the demo's existing
|
||||
free-audio/video streaming and is required by Task 2's free-image viewer. Verify
|
||||
the demo/dev proxy actually forwards /api/* to the mock (check vite.config.ts /
|
||||
vite.preview.config.mts proxy config and the demo docker nginx config if present);
|
||||
if /api is not proxied in dev, register the route on whatever path prefix reaches
|
||||
the mock and keep the frontend URL unchanged (the frontend path is fixed — it must
|
||||
work on real nodes too, where nginx proxies /api to the daemon).
|
||||
|
||||
Commit this task on its own (stage mock-backend.js + the demo/ media files
|
||||
explicitly by path; git add demo/content/music demo/peer-media is fine, never
|
||||
git add -A; nothing under indeedhub/).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && node --check mock-backend.js && node mock-backend.js & sleep 2; then RPC-probe localhost:5959: (a) content.browse-peer for each federation onion — every PEER_LIBRARY id appears on >=1 peer, total duplicate ids across all peers <= 3, both Wavlake ids present; (b) content.owned-list returns >=2 seeded items with paid_sats and purchased_at; (c) content.download-peer-paid for the paid Wavlake id returns mime_type audio/mpeg with data length > 1MB base64; (d) curl -H "Range: bytes=0-0" /api/peer-content/{onion}/{free-wavlake-id} returns 206; (e) file demo/peer-media/photo-*.jpg all report JPEG; then kill the mock. (If /rpc/v1 requires a session, log in first with the mock demo password password123.)</automated>
|
||||
</verify>
|
||||
<done>Both Wavlake tracks in the catalog with real committed bytes + real API metadata; first is paid and delivers real audio/mpeg bytes on purchase; owned-list seeded and purchase-persistent; catalog duplication reduced to <=3 intentional items; photos are real JPEGs; /api/peer-content serves Range requests. Committed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Shared viewer routing — free-image lightbox fix, click-to-open, unified post-payment open</name>
|
||||
<files>neode-ui/src/views/PeerFiles.vue</files>
|
||||
<action>
|
||||
These changes ship in BOTH the real-node build and the demo — no IS_DEMO gating,
|
||||
and every path must degrade gracefully against the real backend.
|
||||
|
||||
1. Fix the A8 bug + A7 click routing at the card click handler (line 91). Replace
|
||||
the ternary with a single openItem(item) dispatcher: owned -> viewOwned (existing);
|
||||
paid-unowned playable -> playMedia (existing 10% preview, keep); paid-unowned
|
||||
NON-playable (images, blurred) -> openPayModal(item) so the click gets the
|
||||
appropriate "viewer" for locked content while preserving the paid gating (the
|
||||
full image is never fetched or revealed pre-purchase — only the blurred thumbnail);
|
||||
FREE image -> open the existing Teleport-to-body owned-content viewer modal
|
||||
(template line 310) as a lightbox: set viewerItem/viewerMime and point viewerUrl
|
||||
at the free stream URL /api/peer-content/{onion}/{id} directly (an img src streams
|
||||
it; no base64 round-trip; works on real nodes via the Range proxy and in the demo
|
||||
via Task 1's new route). Free audio/video already route through playMedia — keep.
|
||||
Guard closeViewer's URL.revokeObjectURL so it only revokes blob: URLs.
|
||||
|
||||
2. Generalize the viewer footer (line 354): the hardcoded "Owned · unlocked" green
|
||||
caption must only show for owned items; for free items show a neutral caption
|
||||
(exact string "Free · shared by peer" — also used as the bundle-grep sentinel), and
|
||||
make the footer Save button use the free download path for free items (streamDownload)
|
||||
instead of content.owned-get.
|
||||
|
||||
3. Unify post-payment success handling (A3-adjacent, both builds). payWithLightning
|
||||
(line 1364), pollInvoice (line 1417) and pollOnchain (line 1197) currently
|
||||
triggerDownload on success; align them with confirmEcashPay (line 1277): mark the
|
||||
item owned in ownedKeys, refresh loadOwned, and open in-app — audio ->
|
||||
audioPlayer.play (bottom-bar autoplay), image/video -> the viewer modal — using the
|
||||
mime_type from the download response with item.mime_type as fallback. Extract the
|
||||
shared logic (e.g. an openPurchased(item, data, mime) helper) rather than
|
||||
duplicating it four times. Keep triggerDownload available via the viewer's Save
|
||||
button. Do not touch the payment/polling logic itself.
|
||||
|
||||
4. Do NOT extend the preview-thumbnail watcher (line 872) to audio — on real nodes
|
||||
content.preview-peer returns audio bytes for audio items (see recon), which must
|
||||
not be used as an img src. Audio cards keep the waveform icon.
|
||||
|
||||
5. Sanity-check the other previews-grid views for the same A8 class of bug:
|
||||
CloudFolder.vue / Cloud.vue My Files use FileCard @preview -> handlePreview ->
|
||||
MediaLightbox and should already open free images; verify by reading the handler
|
||||
chain (no change expected — do not modify them if they work).
|
||||
|
||||
6. Keep src/views/__tests__/PeerFilesRefresh.test.ts green; if the click-dispatch
|
||||
refactor is cheaply testable, extend that test file with a case asserting a free
|
||||
image click sets viewerUrl (do not build new test infrastructure).
|
||||
|
||||
Commit this task separately (stage neode-ui/src paths explicitly).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && npx vitest run src/views/__tests__/PeerFilesRefresh.test.ts src/composables/__tests__/useAudioPlayer.test.ts</automated>
|
||||
</verify>
|
||||
<done>Clicking a free image in PeerFiles opens the full-screen viewer (Teleport-to-body, backdrop preserved); free audio -> bottom bar; free video -> video modal; paid unowned image click opens the pay modal and never reveals the image; all four payment-success paths open purchased content in-app with audio autoplaying in the bottom bar. Targeted tests green. Committed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Full test suite, production build, bundle verification, demo smoke</name>
|
||||
<files>(no new files — verification only; fixes belong to the task that broke them)</files>
|
||||
<action>
|
||||
1. Run the full unit suite: cd neode-ui && npx vitest run — all tests (currently
|
||||
195) must pass. Fix any regression in the file that caused it, amending or adding
|
||||
a fixup commit to the responsible task's change.
|
||||
2. Production build per CLAUDE.md: cd neode-ui && npm run build (vue-tsc must pass;
|
||||
output lands in web/dist/neode-ui). Grep the built bundle for the Task 2 sentinel
|
||||
string to prove the build did not silently no-op:
|
||||
grep -rl "Free · shared by peer" ../web/dist/neode-ui/assets/ must match at least
|
||||
one js file.
|
||||
3. End-to-end demo smoke against the mock: start node mock-backend.js plus the dev
|
||||
frontend (or vite preview against the built dist) and exercise via curl/RPC: search
|
||||
corpus contains the Wavlake titles (browse-peer aggregation), paid purchase of the
|
||||
Wavlake track deducts ecash and returns audio/mpeg, owned-list grows by the
|
||||
purchase, /api/peer-content serves the free track with 206. Confirm no occurrence
|
||||
of 146.59.87.168 in mock-backend.js additions or demo-served data:
|
||||
grep -rn "146.59.87.168" neode-ui/mock-backend.js demo/ must be empty.
|
||||
4. Confirm git hygiene: git status shows nothing staged under indeedhub/; all work
|
||||
is committed across the task commits (code only — the summary doc is the
|
||||
orchestrator's commit). Leave deploy/push to the orchestrator.
|
||||
5. Write into the task summary a post-deploy live checklist for
|
||||
http://146.59.87.168:2100 (orchestrator deploys): search finds the Wavlake tracks;
|
||||
buy the paid track with ecash -> bottom bar autoplays; Paid Files tab shows seeded
|
||||
purchases; peer catalog shows <=3 duplicated files; photos are real and open in the
|
||||
lightbox on click; free song click plays in bottom bar.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && npx vitest run && npm run build && grep -rl "Free · shared by peer" ../web/dist/neode-ui/assets/ | head -1 && ! grep -rn "146.59.87.168" mock-backend.js ../demo/</automated>
|
||||
</verify>
|
||||
<done>Full suite green, production build succeeds, bundle grep proves the new UI string shipped, demo smoke passes, no IP leak, clean git state with per-task commits.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| executor -> catalog.wavlake.com / CloudFront / Wikimedia | External bytes fetched at execution time get committed into the repo and served by the demo |
|
||||
| demo visitor -> mock backend | Untrusted public visitors hit the new /api/peer-content route |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-hj1-01 | Tampering | Downloaded mp3/jpg assets | medium | mitigate | Verify every download with file(1) magic-type + plausible size before committing; fetch only from the verified catalog.wavlake.com/CloudFront/Wikimedia URLs over https |
|
||||
| T-hj1-02 | Information Disclosure | Demo-served data | high | mitigate | Task 3 gate: grep for 146.59.87.168 across mock-backend.js and demo/ must be empty |
|
||||
| T-hj1-03 | Tampering (path traversal) | /api/peer-content route | medium | mitigate | Route resolves content_id strictly against PEER_LIBRARY entries (whitelist lookup, never a filesystem path built from request input) |
|
||||
| T-hj1-04 | Elevation | Paid-content gating in PeerFiles.vue | medium | mitigate | Paid-unowned image click opens the pay modal only; the free-image viewer path is reachable solely when access !== paid; no pre-purchase full-content fetch is added |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `cd neode-ui && npx vitest run` — full suite green (195 tests baseline).
|
||||
- `cd neode-ui && npm run build` — vue-tsc + vite build succeed; bundle grep for "Free · shared by peer" hits.
|
||||
- Mock RPC smoke: Wavlake items searchable via browse-peer aggregation; paid purchase returns audio/mpeg real bytes and appends to owned-list; duplicate ids across all 12 peers <= 3; /api/peer-content answers 206 to a Range probe; owned-list seeded with dated purchases.
|
||||
- `git log --oneline` shows one focused commit per task; nothing staged under indeedhub/.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- A1/A2: both Wavlake tracks (real API metadata, real committed bytes) searchable in the peer-files search.
|
||||
- A3: first Wavlake track is paid; demo ecash purchase autoplays it in the bottom GlobalAudioPlayer bar.
|
||||
- A4: Cloud -> Paid Files shows seeded purchase history; audio purchases play on click; new purchases persist in the list.
|
||||
- A5: at most 2-3 deliberately duplicated files across the aggregated peer catalog.
|
||||
- A6: all photo peer files are real photographs matching their descriptions.
|
||||
- A7/A8 (both builds): free image click opens the full-screen viewer, free audio -> bottom player, free video -> video modal, paid gating preserved; all payment paths open purchased media in-app.
|
||||
- Non-demo build safe: tests green, build clean, frontend degrades gracefully without demo-only mock data.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
On completion the executor reports per-task commit hashes and the post-deploy live
|
||||
verification checklist for http://146.59.87.168:2100 (deployment is the orchestrator's
|
||||
job; docs/summary committed by the orchestrator).
|
||||
</output>
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
---
|
||||
phase: quick-260729-hj1
|
||||
plan: 01
|
||||
subsystem: demo / peer-files
|
||||
status: complete
|
||||
requirements: [A1, A2, A3, A4, A5, A6, A7, A8]
|
||||
key-files:
|
||||
created:
|
||||
- "demo/content/music/Zazawowow - WEBFIVEFOURTHREETWOONE.mp3 (6,041,903 B, MPEG audio)"
|
||||
- "demo/content/music/Zazawowow - Michael Michael Saylor.mp3 (4,517,590 B, MPEG audio)"
|
||||
- demo/peer-media/artwork-webfive.jpg (1400x1400 JPEG)
|
||||
- demo/peer-media/artwork-michael-saylor.jpg (1400x1400 JPEG)
|
||||
modified:
|
||||
- neode-ui/mock-backend.js
|
||||
- neode-ui/vite.config.ts
|
||||
- neode-ui/src/views/PeerFiles.vue
|
||||
- neode-ui/src/views/__tests__/PeerFilesRefresh.test.ts
|
||||
- demo/peer-media/photo-*.jpg (all 10 replaced with real Wikimedia Commons photos)
|
||||
commits:
|
||||
- "14d1a453 feat(demo): Wavlake tracks, real photos, deduped peer catalog, working paid flow"
|
||||
- "f52c5407 fix(peer-files): free-image lightbox, click-to-open routing, in-app open after every payment rail"
|
||||
metrics:
|
||||
duration: 64m
|
||||
tasks: 3
|
||||
completed: 2026-07-29
|
||||
---
|
||||
|
||||
# Quick Task 260729-hj1: Peer-Files Media Batch (Wavlake, Paid Track, Real Photos) Summary
|
||||
|
||||
Two real Wavlake tracks (paid buy->autoplay flow now delivers real mp3 bytes), seeded purchase history, deduped peer catalog, ten real Commons photographs, and a shared-frontend fix so every peer file click opens the right viewer (free images finally open in the lightbox).
|
||||
|
||||
## What Was Done
|
||||
|
||||
### Task 1 — Demo dataset (commit 14d1a453, demo/mock only)
|
||||
|
||||
- **Wavlake tracks (A1-A3):** metadata fetched live from `catalog.wavlake.com/v1/tracks/{id}` (title/artist/album/duration confirmed against plan recon), mp3 bytes + 1400x1400 artwork downloaded from the CloudFront CDN and committed. `song-webfive` ("WEBFIVEFOURTHREETWOONE" by Zazawowow, 21 sats, PAID) and `song-michael-saylor` (free). Both verified as real MPEG audio with `file(1)`.
|
||||
- **Real paid bytes (A3):** the shared `content.owned-get` / `download-peer-paid` / `-invoice` / `-onchain` handler now returns real disk bytes with the correct `mime_type` when the entry has them (text placeholder only for the fictional no-disk films/books). Ecash rail-deduction logic preserved verbatim.
|
||||
- **Purchase history (A4):** per-session `sessionOwnedContent()` store lazily seeds 3 dated purchases (song-builders 100 sats — audio with real bytes; film-the-signal 2100; book-cypherpunk-essays 210), computed against the session's own federation onions/catalog slices. Every purchase path appends, so Owned badges survive the post-purchase `loadOwned()` refresh. `song-builders` was converted from free to paid (100 sats) so the seeded history contains a playable paid audio item without pre-owning the showcase Wavlake track.
|
||||
- **Dedupe (A5):** `peerCatalogFor` is now a deterministic one-peer-per-item assignment (index % 12 against the session's node order) plus a 3-item POPULAR list (`song-webfive`, `film-block-height`, `photo-aurora-fjord`) that appears on exactly one extra peer each. Verified: 29/29 ids present, exactly 3 duplicated ids, both Wavlake tracks on trusted peers.
|
||||
- **Session-stable onions:** `demoFederationNodes()` was regenerated (fresh random onions) on every RPC call; it is now memoised per session (`sessionFederationNodes()`) so catalogs, owned records and the Federation view agree.
|
||||
- **Real photos (A6):** all ten `photo-*.jpg` picsum placeholders replaced with real Wikimedia Commons photographs (aurora over Lofoten fjord, Lago di Limides/Dolomites, Dotonbori Osaka neon, Erg Chebbi dunes, Black Forest mist, Cliffs of Moher, Iceland winter road, Stowe VT autumn, St Ives harbour, Grindelwald ridge hiker). Each is >=1920px wide, visually inspected, license-credited in its description; `size_bytes` updated to real sizes; filenames kept so `PEER_MEDIA` refs are unchanged.
|
||||
- **Streaming route:** new `GET /api/peer-content/:onion/:content_id` serving `entry.disk` via `res.sendFile` (Range/206 works — probed). Whitelist lookup only (T-hj1-03), paid entries return 403 so full paid bytes are unreachable without purchase. Demo nginx already proxies `/api/` -> :5959; added the missing `/api` proxy to the vite dev config so dev works too.
|
||||
- **Paid audio preview:** `content.preview-peer` serves a ~10% leading slice of the real mp3 for audio entries (the Preview button plays it), images unchanged.
|
||||
|
||||
### Task 2 — Shared viewer routing (commit f52c5407, both builds)
|
||||
|
||||
- **A8 fix + A7 routing:** card click goes through `openItem()`: owned -> cached viewer/bottom bar; paid playable -> 10% preview; paid non-playable -> pay modal (image never fetched pre-purchase, T-hj1-04); free image -> existing Teleport-to-body viewer as a lightbox with `viewerUrl` pointed at the stream URL (no base64 round-trip); free audio/video -> `playMedia` (bottom bar / video modal).
|
||||
- **Viewer footer:** "Owned · unlocked" (green) only for owned items; "Free · shared by peer" (neutral) otherwise — also the bundle-grep sentinel. Save button streams free files (`streamDownload`) instead of calling `content.owned-get`.
|
||||
- **Unified post-payment:** `payWithLightning`, `pollInvoice` and `pollOnchain` now share `openPurchased()` with the ecash flow — mark owned, refresh owned list, audio autoplays in the bottom bar, image/video open in the viewer. `triggerDownload` remains for the explicit Save path.
|
||||
- **Blob-URL hygiene:** `releaseViewerUrl()` only revokes `blob:` URLs (free items use plain URLs).
|
||||
- Preview-thumbnail watcher deliberately NOT extended to audio (real nodes return audio bytes there). Cloud.vue/CloudFolder.vue verified to already open free images via MediaLightbox — untouched.
|
||||
- Regression test added: free image click opens the lightbox.
|
||||
|
||||
### Task 3 — Verification
|
||||
|
||||
- Full unit suite: **697/697 passed** (baseline in plan said 195; suite has since grown — all green).
|
||||
- `npm run build` (vue-tsc + vite): success; `grep -rl "Free · shared by peer" web/dist/neode-ui/assets/` hits `PeerFiles-DsotBwvS.js` (build did not no-op).
|
||||
- Mock RPC smoke (20 checks, all pass): browse-peer aggregation contains both Wavlake titles; paid purchase deducts 21 sats ecash and returns `audio/mpeg` >1MB; purchase appended to owned-list; `/api/peer-content` answers 206 to a Range probe, 403 for paid items, 404 for unknown/traversal ids; paid audio preview is audio bytes; image previews still jpeg.
|
||||
- `grep -rn "146.59.87.168" neode-ui/mock-backend.js demo/` — empty (T-hj1-02).
|
||||
- Git: two focused code commits, nothing staged under `indeedhub/`, only other agents' pre-existing untracked files remain.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
**1. [Rule 3 - Blocking] Session-memoised federation nodes.** The plan assumed onions were "random per session"; they were actually random per RPC call, which would have made seeded owned-content onions never match what the frontend sees. Added `sessionFederationNodes()` memoisation (per-visitor via the existing session store). Commit 14d1a453.
|
||||
|
||||
**2. [Rule 2 - Missing critical] `/api` dev proxy in vite.config.ts.** The demo nginx proxies `/api/` to the mock, but the vite dev server did not — the new route (and the existing `/api/blob`, `/api/app-catalog`) would 404 on :8100 dev. Added a `/api` proxy entry (dev-server-only config; real nodes use nginx). Commit 14d1a453.
|
||||
|
||||
**3. [Minor scope choice] `song-builders` converted free -> paid (100 sats).** The plan required a seeded audio purchase backed by real disk bytes, but the only paid audio item is the showcase Wavlake track, which must NOT be pre-owned (it would kill the A3 buy demo). Making one existing song paid provides a legitimately purchasable audio item for the seeded history and an Owned-badge example in the gallery.
|
||||
|
||||
## Post-Deploy Live Checklist — http://146.59.87.168:2100 (orchestrator deploys)
|
||||
|
||||
1. **Search (A1/A2):** Cloud -> search "Zazawowow" (or "WEBFIVE" / "Michael") — both tracks appear as peer-file results.
|
||||
2. **Paid buy -> autoplay (A3):** open the peer holding "Zazawowow - WEBFIVEFOURTHREETWOONE.mp3" (21 sats), Buy -> ecash -> Pay: the bottom GlobalAudioPlayer bar appears and the track audibly plays; card flips to green "Owned".
|
||||
3. **Preview before buying (A3):** the paid track's Preview button plays ~25s of real audio, not silence/artwork.
|
||||
4. **Paid Files tab (A4):** Cloud -> Paid Files shows 3+ seeded purchases with sats + dates; clicking "Builders, not talkers (Remastered).mp3" plays it in the bottom bar; the fresh Wavlake purchase from step 2 is now listed too.
|
||||
5. **Dedupe (A5):** browsing several peers, each file appears on ~1 peer; only 3 files (WEBFIVE track, Block Height film, aurora photo) appear on two.
|
||||
6. **Real photos (A6):** photo cards show real photographs (aurora, Dolomites lake, Osaka neon, dunes, mist, cliffs, Iceland road, Vermont autumn, St Ives, Grindelwald) — no picsum grey placeholders.
|
||||
7. **Free image lightbox (A8):** clicking any photo card opens the full-screen viewer with backdrop; footer reads "Free · shared by peer"; Save downloads it.
|
||||
8. **Free audio/video (A7):** clicking "Zazawowow - Michael Michael Saylor.mp3" (free) plays in the bottom bar; a free video (if on the browsed peer) opens the video modal.
|
||||
9. **Paid gating (A7):** a blurred paid image click opens the pay modal, never the image; paying via the Lightning QR path also opens the content in-app (no orphan browser download).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- demo/content/music/Zazawowow - WEBFIVEFOURTHREETWOONE.mp3 — FOUND (MPEG audio)
|
||||
- demo/content/music/Zazawowow - Michael Michael Saylor.mp3 — FOUND (MPEG audio)
|
||||
- demo/peer-media/artwork-webfive.jpg, artwork-michael-saylor.jpg — FOUND (JPEG)
|
||||
- All 10 demo/peer-media/photo-*.jpg — FOUND (JPEG, >=1920px wide)
|
||||
- Commit 14d1a453 — FOUND in git log
|
||||
- Commit f52c5407 — FOUND in git log
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
---
|
||||
phase: quick-260729-je5
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- neode-ui/src/views/web5/Web5ConnectedNodes.vue
|
||||
- neode-ui/src/App.vue
|
||||
- neode-ui/src/views/RootRedirect.vue
|
||||
autonomous: true
|
||||
requirements: [QUICK-JE5-01, QUICK-JE5-02]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Connected-nodes card (dashboard Web5 view): the visible nodes list grows/shrinks to fill the card so the gap above the Find Nodes / Refresh buttons is a constant pt-4, regardless of how tall the sibling Node Visibility card makes the xl 2-col grid row (QUICK-JE5-01)"
|
||||
- "Below xl (single-column/mobile) the list keeps its current max-h-72 cap — no visual/sizing change (QUICK-JE5-01)"
|
||||
- "Demo build opened inside the Android companion WebView shows NO typing splash and NO /onboarding/intro — it lands on /login as if the intro was already seen (QUICK-JE5-02)"
|
||||
- "Browser/PWA demo intro behavior is byte-identical to today (replays on every fresh root boot); the skip path writes NOTHING to localStorage (QUICK-JE5-02)"
|
||||
- "Non-demo builds are a complete no-op for both changes' runtime behavior (QUICK-JE5-02)"
|
||||
- "All existing unit tests stay green (697) and npm run build succeeds with the new strings present in the built bundle"
|
||||
artifacts:
|
||||
- "neode-ui/src/views/web5/Web5ConnectedNodes.vue (flex/scroll fix, no design change)"
|
||||
- "neode-ui/src/App.vue (companion+demo intro gate at both IS_DEMO branch sites)"
|
||||
- "neode-ui/src/views/RootRedirect.vue (companion+demo intro gate at both IS_DEMO branch sites)"
|
||||
key_links:
|
||||
- "isCompanionApp() from neode-ui/src/utils/openExternal.ts is the single companion-detection source at all four IS_DEMO intro branch sites (same convention as stores/appLauncher.ts lines 224/329)"
|
||||
- "Web5ConnectedNodes.vue card root is already `flex flex-col`; the fix works entirely inside that column flex (list = flexible middle, footer = non-shrinking bottom)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Two small, isolated UI fixes in neode-ui (shared by real + demo builds):
|
||||
|
||||
1. **QUICK-JE5-01 — Connected-nodes list flex fix (both builds):** the scrollable nodes
|
||||
list in the dashboard "Connected Nodes" card must always end at a consistent margin
|
||||
above the bottom Find Nodes / Refresh buttons, even when the sibling card
|
||||
(Web5NodeVisibility) stretches the shared grid row. Today the list is hard-capped at
|
||||
`max-h-72` and the footer uses `mt-auto`, so a tall sibling opens a growing dead gap
|
||||
between list and buttons.
|
||||
|
||||
2. **QUICK-JE5-02 — Companion app skips demo intro (demo build only):** when the demo
|
||||
runs inside the Android companion WebView (`window.ArchipelagoNative` bridge
|
||||
injected), skip the typing splash + `/onboarding/intro` entirely and land on /login,
|
||||
without touching any state the browser demo relies on. Desktop/PWA browser demo
|
||||
intro must be completely unaffected — the user resets and relies on it before demos.
|
||||
|
||||
Purpose: fix a visible layout bug on every node dashboard, and stop the companion app
|
||||
from replaying the demo cinematic every time the demo is opened in-app.
|
||||
Output: 2 focused commits on main (frontend only), tests green, verified build.
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
@/home/archipelago/Projects/archy/CLAUDE.md
|
||||
@/home/archipelago/Projects/archy/neode-ui/src/views/web5/Web5ConnectedNodes.vue
|
||||
@/home/archipelago/Projects/archy/neode-ui/src/utils/openExternal.ts
|
||||
@/home/archipelago/Projects/archy/neode-ui/src/composables/useDemoIntro.ts
|
||||
|
||||
Key facts already established (do not re-derive):
|
||||
- The "connected nodes" container is `neode-ui/src/views/web5/Web5ConnectedNodes.vue`.
|
||||
Its card root (line 3) is already `glass-card p-6 ... flex flex-col`. The three tab
|
||||
panes (Trusted ~line 57, Observers ~line 90, Requests ~line 120) each use
|
||||
`class="space-y-2 max-h-72 overflow-y-auto"` with `v-show`, and the button footer
|
||||
(~line 161) is `<div class="mt-auto pt-4 space-y-3">`. The row is
|
||||
`neode-ui/src/views/web5/Web5.vue` line 59: `grid grid-cols-1 xl:grid-cols-2 gap-6`
|
||||
with sibling `Web5NodeVisibility` (grid items stretch to row height by default).
|
||||
- Companion detection already exists: `isCompanionApp()` in
|
||||
`neode-ui/src/utils/openExternal.ts` (true iff the native shell injected
|
||||
`window.ArchipelagoNative` with an `openInApp` function; a plain browser/PWA never
|
||||
has it, and the bridge exists before page scripts run — appLauncher.ts already
|
||||
relies on it synchronously).
|
||||
- The demo intro fires from exactly four IS_DEMO branch sites:
|
||||
- `neode-ui/src/App.vue` ~line 433: `if (IS_DEMO && bootPath === '/') replayRequested = true` (typing splash on every root boot)
|
||||
- `neode-ui/src/App.vue` ~line 588: post-splash `if (IS_DEMO) { router.push('/onboarding/intro'); reveal(); return }`
|
||||
- `neode-ui/src/views/RootRedirect.vue` ~lines 82 and 149: `if (IS_DEMO) { demoRoute() }` → pushes `/onboarding/intro`
|
||||
- `views/web5/__tests__/Web5ConnectedNodes.test.ts` exists but does NOT assert on
|
||||
`max-h-72` / `mt-auto` classes (verified by grep) — class changes should not break it.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Connected-nodes list fills the card — constant gap above footer buttons (QUICK-JE5-01)</name>
|
||||
<files>neode-ui/src/views/web5/Web5ConnectedNodes.vue</files>
|
||||
<action>
|
||||
Implement the column-flex fix per QUICK-JE5-01, exactly as scoped: flexible
|
||||
scrollable list + non-shrinking footer, replacing the fixed cap as the xl-row
|
||||
sizing mechanism. Concretely:
|
||||
|
||||
1. On EACH of the three v-show tab panes (Trusted, Observers, Requests — the divs
|
||||
currently classed `space-y-2 max-h-72 overflow-y-auto`), change the classes to:
|
||||
`space-y-2 flex-auto min-h-0 overflow-y-auto max-h-72 xl:max-h-none`
|
||||
- `flex-auto` (flex: 1 1 auto) + `min-h-0` is the standard fix: the visible pane
|
||||
becomes the flexible middle of the column-flex card, growing to absorb any
|
||||
extra height the grid row imposes and shrinking (with internal scroll) when
|
||||
constrained — so the space between list end and footer is always exactly the
|
||||
footer's own pt-4.
|
||||
- Keep `max-h-72` ONLY below xl (`xl:max-h-none` lifts it): below xl the grid is
|
||||
single-column (`grid-cols-1`), no sibling stretches the card, and the current
|
||||
mobile sizing must not change (constraint: no visual design change).
|
||||
- Hidden panes are `v-show` (display:none) so applying flex classes to all three
|
||||
is safe — only the visible one participates in layout.
|
||||
2. On the footer div (`mt-auto pt-4 space-y-3`), add `shrink-0` so the buttons can
|
||||
never be compressed by a long list. Keep `mt-auto` (harmless once the list is
|
||||
flex-auto — it only matters in the sub-xl capped case, where it preserves today's
|
||||
behavior exactly).
|
||||
3. Touch NOTHING else in the component: no color, spacing, typography, or markup
|
||||
changes. The card root already has `flex flex-col` — do not restructure it.
|
||||
4. Sanity-check the sibling row in `neode-ui/src/views/web5/Web5.vue` line 59 (read
|
||||
only): default grid item stretch is what feeds the card its height — no change
|
||||
needed there.
|
||||
|
||||
This component is shared by real and demo builds, so one fix covers both builds.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/archipelago/Projects/archy/neode-ui && npx vitest run src/views/web5/__tests__/Web5ConnectedNodes.test.ts</automated>
|
||||
Also: grep the component to confirm no pane retains a bare `max-h-72` without `xl:max-h-none`, and that all three panes have `flex-auto min-h-0`.
|
||||
</verify>
|
||||
<done>
|
||||
All three tab panes are `flex-auto min-h-0 overflow-y-auto max-h-72 xl:max-h-none`;
|
||||
footer has `shrink-0`; component test file passes; no other visual changes.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Companion WebView + demo build skips the intro entirely (QUICK-JE5-02)</name>
|
||||
<files>neode-ui/src/App.vue, neode-ui/src/views/RootRedirect.vue</files>
|
||||
<action>
|
||||
Gate all four IS_DEMO intro branch sites on NOT-companion, per QUICK-JE5-02. Use the
|
||||
existing `isCompanionApp()` from `@/utils/openExternal` (do NOT invent new
|
||||
detection — this is the same convention appLauncher.ts uses at lines 224/329, and
|
||||
the `window.ArchipelagoNative` bridge is injected by the native shell before page
|
||||
scripts run, so it is safe to call synchronously at boot).
|
||||
|
||||
1. `neode-ui/src/App.vue` (~line 433): change
|
||||
`if (IS_DEMO && bootPath === '/') replayRequested = true`
|
||||
to also require `!isCompanionApp()` — companion never requests the demo
|
||||
splash replay.
|
||||
2. `neode-ui/src/App.vue` (~line 588): gate the post-splash
|
||||
`if (IS_DEMO) { router.push('/onboarding/intro') ... }` block with
|
||||
`!isCompanionApp()`. When companion+demo, prefer routing DIRECTLY to '/login'
|
||||
and `reveal()` (mirroring the "seenOnboarding === true" branch just below)
|
||||
rather than falling through to `checkOnboardingStatus()` — the mock backend
|
||||
reports onboarded and would land on /login anyway, but the direct route avoids
|
||||
the status-check retry ladder and any splash-adjacent behavior. Add a one-line
|
||||
comment: companion in-app demo skips the intro; browser demo unaffected.
|
||||
3. `neode-ui/src/views/RootRedirect.vue` (~lines 82 and 149): gate both
|
||||
`if (IS_DEMO) { demoRoute() }` calls the same way. When IS_DEMO and
|
||||
isCompanionApp(), route to '/login' (behaving exactly as an intro-already-seen
|
||||
demo session) instead of demoRoute(). Import `isCompanionApp` from
|
||||
'@/utils/openExternal' (static import is fine — the module is tiny and already
|
||||
in the main bundle).
|
||||
4. HARD invariants (from the task constraints):
|
||||
- Write NOTHING to localStorage/sessionStorage from any skip path (no
|
||||
`neode_intro_seen`, no `demo_intro_date`, nothing) — the browser demo's
|
||||
manually-reset intro state must be untouched.
|
||||
- Non-demo builds: `IS_DEMO` is false, so every gated branch short-circuits
|
||||
before `isCompanionApp()` matters — verify by inspection that no new code
|
||||
runs outside `IS_DEMO === true` paths (keep `isCompanionApp()` on the RIGHT
|
||||
side of the `&&` / inside the IS_DEMO block).
|
||||
- Browser/PWA demo: `isCompanionApp()` is false (no bridge) — all four sites
|
||||
behave byte-identically to today.
|
||||
5. If an existing unit test covers RootRedirect demo routing, update/extend it; if
|
||||
cheap, add a small test asserting `isCompanianApp`-style bridge detection drives
|
||||
the skip (stub `window.ArchipelagoNative = { openInApp: () => {} }`). Do not
|
||||
build heavy test scaffolding — the 697 existing tests staying green is the gate.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/archipelago/Projects/archy/neode-ui && npx vitest run</automated>
|
||||
Full suite green (697+ tests). Then grep both edited files to confirm every
|
||||
intro-triggering IS_DEMO branch also checks `isCompanionApp()`.
|
||||
</verify>
|
||||
<done>
|
||||
All four IS_DEMO intro branch sites (App.vue x2, RootRedirect.vue x2) skip the
|
||||
splash/intro and route to /login when `isCompanionApp()` is true; zero storage
|
||||
writes on the skip path; zero behavior change for browser demo and non-demo builds;
|
||||
full unit suite green.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Build verification + bundle grep + commits</name>
|
||||
<files>neode-ui/ (build only — no new source edits expected)</files>
|
||||
<action>
|
||||
Per CLAUDE.md "Build / verify" and "Commit & push every unit of work":
|
||||
|
||||
1. `cd /home/archipelago/Projects/archy/neode-ui && npm run build` (vue-tsc + vite;
|
||||
outputs to web/dist/neode-ui). Build must succeed with zero type errors.
|
||||
2. Bundle grep (build can silently no-op — always grep the built output):
|
||||
- Fix 1: `grep -rl "xl:max-h-none" /home/archipelago/Projects/archy/web/dist/neode-ui/assets/` must match at least one asset (the new Tailwind class proves the fresh component shipped).
|
||||
- Fix 2: the gate is inside IS_DEMO code, which a non-demo build may fold away, so
|
||||
verify against a scratch demo build:
|
||||
`VITE_DEMO=1 npx vite build --outDir /tmp/claude-1000/-home-archipelago-Projects-archy/3ca40190-d6bb-4f98-9d89-8d2479484065/scratchpad/demo-dist --emptyOutDir`
|
||||
then confirm a JS chunk contains BOTH the `demoRoute` log string and
|
||||
`ArchipelagoNative` (heuristic that the companion gate survived into the demo
|
||||
bundle):
|
||||
`grep -rl "demoRoute" <scratch>/demo-dist/assets/*.js | xargs grep -l "ArchipelagoNative"`
|
||||
Do NOT commit or deploy the scratch demo build — it is verification only.
|
||||
3. Commits (code only — the orchestrator commits .planning docs):
|
||||
- Commit 1: Web5ConnectedNodes.vue flex fix.
|
||||
- Commit 2: App.vue + RootRedirect.vue companion demo-intro skip (plus any test
|
||||
file touched in Task 2).
|
||||
- Stage EXPLICITLY by path (`git add neode-ui/src/...`), never `git add -A`.
|
||||
- NEVER stage anything under `indeedhub/` (git submodule) — check
|
||||
`git status --porcelain` before each commit and confirm no `indeedhub` entries
|
||||
are staged.
|
||||
- Do not commit `web/dist/` build output unless the repo already tracks it AND
|
||||
it changed as a direct product of these fixes (check `git status` — if dist is
|
||||
untracked/ignored, leave it alone).
|
||||
- Messages end with the `Co-Authored-By: Claude ...` trailer per CLAUDE.md.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/archipelago/Projects/archy/neode-ui && npm run build && grep -rl "xl:max-h-none" ../web/dist/neode-ui/assets/ | head -1</automated>
|
||||
Plus the demo-build co-occurrence grep from step 2, and `git log --oneline -2`
|
||||
showing the two focused commits with no indeedhub/ paths in either
|
||||
(`git show --stat` per commit).
|
||||
</verify>
|
||||
<done>
|
||||
`npm run build` green; both bundle greps confirm the new code is in the built
|
||||
output; two focused commits exist, each staged by explicit path, no indeedhub/
|
||||
content, Co-Authored-By trailer present.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- Full unit suite: `cd neode-ui && npx vitest run` — all tests green (697 baseline; new tests may raise the count, zero failures).
|
||||
- `npm run build` succeeds; `web/dist/neode-ui` contains `xl:max-h-none`.
|
||||
- Scratch `VITE_DEMO=1` build contains the companion gate (demoRoute + ArchipelagoNative co-occurrence).
|
||||
- Manual spot-check (optional, dev preview :8100 or `npm run dev:mock`): on a wide (xl) window, pad the Node Visibility card content tall and confirm the connected-nodes list expands so the buttons keep an unchanged pt-4 gap; on a narrow window the card looks exactly as before.
|
||||
- Grep confirms no localStorage writes were added in App.vue/RootRedirect.vue skip paths.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- QUICK-JE5-01: nodes list ends at a constant pt-4 above the bottom buttons at any xl row height; sub-xl sizing unchanged; no visual design changes.
|
||||
- QUICK-JE5-02: companion WebView + demo lands on /login with no splash and no /onboarding/intro; browser/PWA demo and non-demo builds byte-identical in behavior; no intro-state storage writes from the skip path.
|
||||
- Tests green, build verified via bundle grep, two clean path-staged commits, nothing from indeedhub/ touched.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
On completion create `.planning/quick/260729-je5-ui-fixes-connected-nodes-scrollable-list/260729-je5-SUMMARY.md` (committed by the orchestrator, not the executor).
|
||||
</output>
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
---
|
||||
phase: quick-260729-je5
|
||||
plan: 01
|
||||
subsystem: neode-ui
|
||||
tags: [web5, layout, demo, companion, onboarding-intro]
|
||||
requirements: [QUICK-JE5-01, QUICK-JE5-02]
|
||||
dependency-graph:
|
||||
requires: []
|
||||
provides:
|
||||
- "Connected-nodes card list flexes to fill xl grid-row height (constant pt-4 gap above footer buttons)"
|
||||
- "Companion WebView + demo build skips typing splash + /onboarding/intro, lands on /login"
|
||||
affects: [neode-ui demo build, companion app demo UX, Web5 dashboard]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Column-flex scroll pane: flex-auto min-h-0 overflow-y-auto with breakpoint-lifted max-h cap (max-h-72 xl:max-h-none)"
|
||||
- "isCompanionApp() as the single companion-detection source at IS_DEMO branch sites (same convention as appLauncher.ts)"
|
||||
key-files:
|
||||
created:
|
||||
- neode-ui/src/utils/__tests__/openExternal.test.ts
|
||||
modified:
|
||||
- neode-ui/src/views/web5/Web5ConnectedNodes.vue
|
||||
- neode-ui/src/App.vue
|
||||
- neode-ui/src/views/RootRedirect.vue
|
||||
decisions:
|
||||
- "RootRedirect skip paths deliberately do NOT call log() — log() writes sessionStorage (archipelago_boot_log) and the skip path must write nothing to storage"
|
||||
- "Cheap test option chosen: unit test for isCompanionApp() bridge detection (the skip's driving mechanism) instead of heavy RootRedirect mount scaffolding"
|
||||
metrics:
|
||||
duration: ~15m
|
||||
completed: 2026-07-29
|
||||
tasks: 3
|
||||
tests: "700 passed (697 baseline + 3 new)"
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick Task 260729-je5: Connected-Nodes Scrollable List + Companion Demo Intro Skip Summary
|
||||
|
||||
Connected-nodes list now flexes to fill the xl grid-row (constant pt-4 gap above Find Nodes/Refresh) and the Android companion demo skips the intro straight to /login via isCompanionApp() gates at all four IS_DEMO branch sites, with zero storage writes.
|
||||
|
||||
## Task Commits
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
| ---- | ---- | ------ | ----- |
|
||||
| 1 | Connected-nodes list fills card (QUICK-JE5-01) | `b80e7c34` | Web5ConnectedNodes.vue |
|
||||
| 2 | Companion+demo intro skip (QUICK-JE5-02) | `d54517cf` | App.vue, RootRedirect.vue, openExternal.test.ts |
|
||||
| 3 | Build verification + bundle greps | — (verification only, no source edits) | — |
|
||||
|
||||
## What Was Done
|
||||
|
||||
### QUICK-JE5-01 — Connected-nodes list flex fix (`b80e7c34`)
|
||||
|
||||
- All three v-show tab panes (Trusted line 57, Observers line 90, Requests line 120) changed from `space-y-2 max-h-72 overflow-y-auto` to `space-y-2 flex-auto min-h-0 overflow-y-auto max-h-72 xl:max-h-none` — the visible pane is now the flexible middle of the card's existing column flex, growing to absorb row height from a tall sibling Web5NodeVisibility card and scrolling internally when constrained.
|
||||
- Below xl (single-column grid) the `max-h-72` cap remains — sub-xl sizing byte-identical.
|
||||
- Footer div gets `shrink-0` (kept `mt-auto`) so the buttons can never be compressed.
|
||||
- No other markup/design changes; Web5.vue grid row (line 59) untouched as planned.
|
||||
|
||||
### QUICK-JE5-02 — Companion demo intro skip (`d54517cf`)
|
||||
|
||||
All four IS_DEMO intro branch sites gated on the existing `isCompanionApp()` from `@/utils/openExternal` (static import added to both files):
|
||||
|
||||
1. `App.vue` line 435: `if (IS_DEMO && bootPath === '/' && !isCompanionApp()) replayRequested = true` — companion never requests the splash replay; with the mock backend reporting onboarded, `shouldShowIntroSplash` then suppresses the splash.
|
||||
2. `App.vue` post-splash block (~line 592): companion+demo routes directly to `/login` + `reveal()` (mirrors the seenOnboarding===true branch), avoiding the status-check retry ladder.
|
||||
3. `RootRedirect.vue` `proceedToApp()` (~line 87): companion+demo → `router.replace('/login')` instead of `demoRoute()`.
|
||||
4. `RootRedirect.vue` onMounted server-up branch (~line 160): same gate.
|
||||
|
||||
**Hard invariants verified:**
|
||||
- Zero storage writes on any skip path — RootRedirect skip paths intentionally do NOT call `log()` because it writes `sessionStorage.archipelago_boot_log`; diff grep for added `localStorage|sessionStorage` lines matched only a comment.
|
||||
- Non-demo builds: `isCompanionApp()` sits on the right of `IS_DEMO &&` / inside `if (IS_DEMO)` blocks — never reached when IS_DEMO is false (compile-time false in non-demo builds; demo scratch bundle confirmed dead-code folding of the non-demo path).
|
||||
- Browser/PWA demo: no bridge → `isCompanionApp()` false → all four sites behave byte-identically (intro replays on every fresh root boot).
|
||||
|
||||
New test `src/utils/__tests__/openExternal.test.ts`: 3 cases asserting bridge detection (no bridge → false; bridge with openInApp → true; bridge without callable openInApp → false).
|
||||
|
||||
## Verification
|
||||
|
||||
- Full unit suite: **700 passed, 0 failed** (697 baseline + 3 new).
|
||||
- `npm run build` (vue-tsc + vite) green; `web/dist/neode-ui/assets/Web5-CB3C73UV.js` contains `xl:max-h-none` (fix 1 shipped).
|
||||
- Scratch `VITE_DEMO=1` build (scratchpad only, not committed): the plan's single-chunk co-occurrence grep did not match because Vite splits chunks — `demoRoute` lives in `RootRedirect-*.js` while `ArchipelagoNative` lives in the shared `index-*.js`/`Dashboard-*.js` chunks. Verified semantically instead (stronger): RootRedirect chunk contains both gated sites compiled as `if(E()){_.replace("/login")...;return}x();return` where `E` is imported from the index chunk whose `isCompanionApp` implementation checks `openInApp=="function"` on `ArchipelagoNative`.
|
||||
- `web/dist` is gitignored — left untouched per plan; no dist output committed.
|
||||
- Both commits path-staged, submodule guard run before each, no `indeedhub/` paths (`git show --stat` clean), `Co-Authored-By: Claude` trailer present.
|
||||
- Pre-existing untracked files from other agents (`.planning/phases/01-.../01-PATTERNS.md`, `scripts/resilience/.gitignore-reports.tmp`) left alone.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed / adjusted
|
||||
|
||||
**1. [Verification method] Demo-bundle co-occurrence grep replaced with per-chunk semantic verification**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** The plan's heuristic (`grep -rl demoRoute ... | xargs grep -l ArchipelagoNative`) assumes both strings land in one JS chunk; Vite's code splitting puts them in different chunks.
|
||||
- **Fix:** Verified the actual gate in the RootRedirect demo chunk (minified `if(E()){replace("/login")}` at both sites, `E` = isCompanionApp import) and the `openInApp=="function"` detection in the index chunk.
|
||||
- **Files modified:** none (verification only).
|
||||
|
||||
No other deviations — plan executed as written.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: neode-ui/src/views/web5/Web5ConnectedNodes.vue (3 panes with flex-auto min-h-0 ... xl:max-h-none; footer shrink-0)
|
||||
- FOUND: neode-ui/src/utils/__tests__/openExternal.test.ts
|
||||
- FOUND: commit b80e7c34 (Task 1)
|
||||
- FOUND: commit d54517cf (Task 2)
|
||||
+440
@@ -0,0 +1,440 @@
|
||||
---
|
||||
phase: quick-260731-upz
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md
|
||||
- docs/security/PSBT-SIGNING-ARCHITECTURE.md
|
||||
- docs/UNIFIED-TASK-TRACKER.md
|
||||
- core/archipelago/src/seed.rs
|
||||
autonomous: false
|
||||
requirements: [QUICK-UPZ-01, QUICK-UPZ-02, QUICK-UPZ-03]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md exists and every single finding carries file:line evidence from the real tree — no finding is asserted from the research file alone (QUICK-UPZ-01)"
|
||||
- "Each of [ARCHY-1] [ARCHY-2] [ARCHY-3] [ARCHY-4] is explicitly CONFIRMED, REFUTED, or UNVERIFIED against real code, and a refuted finding says so plainly rather than being quietly dropped (QUICK-UPZ-01)"
|
||||
- "The audit covers all six mandated secret classes: master BIP-39 seed, LND aezeed, fleet release-root + catalog/manifest signing keys, node identity keys (nostr/FIPS/Reticulum), container::secrets generated_secrets, and session tokens/CSRF (QUICK-UPZ-01)"
|
||||
- "[ARCHY-3] (one-ISO-many-nodes correlation) is answered from image-recipe/ evidence where the tree can answer it, and everything the tree cannot answer is listed in a separate, explicitly-labelled on-node verification checklist marked UNVERIFIED — never asserted as verified (QUICK-UPZ-01)"
|
||||
- "Every finding has Severity (Critical/High/Medium/Low/Informational) + evidence + exploitability + blast radius + concrete remediation, and there is a 'What we do right' section (QUICK-UPZ-01)"
|
||||
- "docs/security/PSBT-SIGNING-ARCHITECTURE.md exists and covers: descriptor-only watch-only wallet, the create->export->sign->import->finalize->broadcast loop with named Core RPCs, single-sig-HW and wsh(sortedmulti) multisig tiers, air-gap transport choice, LND's honest limits, hot wallet as explicitly-secondary, migration for existing hot-seed users, and a phased rollout that a future /gsd-plan-phase can consume (QUICK-UPZ-02)"
|
||||
- "The PSBT spec states plainly that a routing node's channel/revocation/HTLC keys cannot be air-gapped, and splits the design into on-chain (PSBT-protectable) vs lightning (necessarily hot) (QUICK-UPZ-02)"
|
||||
- "No produced document contains any real secret value — no mnemonic words, no private keys, no tokens, no passwords; secrets are referenced by path/variable name only (QUICK-UPZ-01, QUICK-UPZ-02, QUICK-UPZ-03)"
|
||||
- "A prioritised remediation backlog exists in the audit doc and the resulting open items appear in docs/UNIFIED-TASK-TRACKER.md in that file's existing tier/checkbox format (QUICK-UPZ-03)"
|
||||
- "If [ARCHY-1] is confirmed, the mnemonic-generation call site takes its RNG as an injected parameter (OsRng in production) and a test proves the injected RNG is the one actually used — a test that cannot exist before the change; if it is not applied, the audit records why in a greppable line (QUICK-UPZ-03)"
|
||||
- "The other agent's work is untouched: container/secrets.rs, federation/storage.rs, pip.ts, Cloud.vue, OnboardingSeedGenerate.vue and the new composables/ files are read-only in this plan, and no commit authored by this plan contains any of those paths"
|
||||
artifacts:
|
||||
- "docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (evidence-backed audit + prioritised remediation backlog + on-node verification checklist)"
|
||||
- "docs/security/PSBT-SIGNING-ARCHITECTURE.md (watch-only/multisig/air-gap/LND spec + phased rollout)"
|
||||
- "docs/UNIFIED-TASK-TRACKER.md (updated with the resulting open items)"
|
||||
- "core/archipelago/src/seed.rs + its test module (ONLY if [ARCHY-1] is confirmed and the fix is small/obviously-correct/testable)"
|
||||
key_links:
|
||||
- "core/archipelago/src/seed.rs is the fan-out root — every other key class (node Ed25519 did:key, nostr, FIPS, release-root signing, per-identity keys, BIP-84 bitcoin, LND aezeed via HKDF) descends from the one mnemonic, so a defect there has strictly larger blast radius than a hardware wallet's"
|
||||
- "bip39 2.1.0's Mnemonic::generate -> generate_in -> generate_in_with(&mut rand::thread_rng(), ...) is the transitive-default hop that makes the entropy source implicit at Archipelago's call site — this is the exact structural shape of the COLDCARD defect (T1)"
|
||||
- "docs/hardware-signer-design.md already exists (TROPIC01 air-gapped signer, exploratory stub) — the PSBT spec must cross-link it as the future first-party signer, not duplicate or contradict it"
|
||||
- "image-recipe/build-debian-iso.sh + image-recipe/archipelago-scripts/install-to-disk.sh + image-recipe/configs/*.service are the only places that can bake a random-seed or order key generation against crng init — they are the whole [ARCHY-3] evidence surface in-tree"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Turn the confirmed 2026-07-30 Coinkite COLDCARD low-entropy incident into three concrete
|
||||
Archipelago artifacts:
|
||||
|
||||
1. **A real entropy & seed-generation security audit** of this codebase (`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`) —
|
||||
performed against actual code with `file:line` evidence, not a restatement of the research.
|
||||
2. **A PSBT-first signing architecture spec** (`docs/security/PSBT-SIGNING-ARCHITECTURE.md`) —
|
||||
watch-only descriptors, multisig, air-gap transport, honest LND limits, hot wallet as an
|
||||
explicitly-secondary tier, and a phased rollout a future `/gsd-plan-phase` can consume.
|
||||
3. **A prioritised remediation backlog** wired into `docs/UNIFIED-TASK-TRACKER.md`, plus — only
|
||||
if the audit proves it necessary — one small, obviously-correct, independently-tested
|
||||
hardening fix.
|
||||
|
||||
Purpose: Archipelago derives its **entire key hierarchy from one 24-word BIP-39 mnemonic**,
|
||||
including the **fleet release-root signing key**. A Coldcard-class entropy defect here would not
|
||||
just drain wallets, it would let an attacker forge signed release manifests for the whole fleet.
|
||||
The research found no such defect today — but it did find the *structural shape* that produced
|
||||
T1, and it flagged the ISO/first-boot entropy story as the single most plausible real exposure.
|
||||
|
||||
Output: 3 commits on `main` (audit doc, spec doc, backlog + optional fix), pushed via `gitea-ai`.
|
||||
|
||||
**This is an audit-and-spec task, not a feature build.** Do NOT implement PSBT, watch-only,
|
||||
multisig, or any wallet/signing behaviour. Do NOT refactor beyond the single gated fix in Task 3.
|
||||
|
||||
**Tracer-first decomposition deliberately does not apply here** — there are no layers to slice
|
||||
through; this is a deliverable set. Task 1 is the load-bearing evidence pass and Tasks 2 and 3
|
||||
strictly depend on its findings. Execute the three tasks **in order**.
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
@/home/archipelago/Projects/archy/CLAUDE.md
|
||||
@/home/archipelago/Projects/archy/.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md
|
||||
|
||||
**Read the RESEARCH.md above IN FULL before starting.** It contains the confirmed incident
|
||||
analysis, the historical low-entropy catalogue (T1–T7), the greppable audit checklist with
|
||||
exact dangerous/correct API names, findings [ARCHY-1]…[ARCHY-5], and the LND capability matrix.
|
||||
Use it to aim the audit and to justify the spec's technical choices — do **not** re-derive it,
|
||||
and do **not** copy it wholesale into the deliverables.
|
||||
|
||||
## Facts already established — do not re-derive
|
||||
|
||||
- **The RNG-touching surface in this tree is bounded and already enumerated.** 33 Rust files
|
||||
under `core/*/src` and 7 TypeScript/Vue files under `neode-ui/src` match the RNG API set.
|
||||
Task 1's grep pipeline reproduces exactly this set. There is no need to read the whole repo.
|
||||
- **Dependency versions** (`core/archipelago/Cargo.toml`): `rand = "0.8.5"` (still has fork
|
||||
protection; 0.9.0 removed it), `bip39 = { version = "=2.1.0", features = ["rand"] }`
|
||||
(2.2.2 is current — do not bump in this task), `argon2 = "0.5.3"`, `zeroize = "1.8.2"`.
|
||||
- **`docs/security/` does not exist yet** — create it.
|
||||
- **`docs/hardware-signer-design.md` already exists** (171 lines, 2026-06-24, exploratory
|
||||
TROPIC01 air-gapped-signer stub). Task 2 must cross-link and stay consistent with it.
|
||||
- **`docs/UNIFIED-TASK-TRACKER.md`** is 268 lines, organised as `## Tier 0 — Quick / mechanical,
|
||||
no blockers`, `## Tier 1 — Medium effort, unblocked`, etc., with `- [ ]` / `- [x] ~~struck~~`
|
||||
items, ordered fastest/simplest first. Match that format exactly.
|
||||
- **`image-recipe/_archived/` is out of audit scope** (dead auto-installer path). Note it as
|
||||
explicitly excluded in the audit doc so the next auditor does not re-derive that.
|
||||
- **`core/archipelago/src/seed.rs` is CLEAN** in git (safe to edit in Task 3). Its test module
|
||||
starts at line 479.
|
||||
|
||||
## CONCURRENT-AGENT HAZARD — read carefully
|
||||
|
||||
Another agent has uncommitted work in this shared tree. These files are **dirty**:
|
||||
|
||||
```
|
||||
core/archipelago/src/container/secrets.rs <- IN AUDIT SCOPE, read-only
|
||||
core/archipelago/src/federation/storage.rs
|
||||
neode-ui/src/utils/pip.ts
|
||||
neode-ui/src/views/Cloud.vue
|
||||
neode-ui/src/views/OnboardingSeedGenerate.vue <- IN AUDIT SCOPE, read-only
|
||||
neode-ui/src/composables/usePaidItemViewer.ts (untracked)
|
||||
neode-ui/src/composables/usePipSession.ts (untracked)
|
||||
neode-ui/src/composables/__tests__/*.test.ts (untracked)
|
||||
```
|
||||
|
||||
`container/secrets.rs` and `OnboardingSeedGenerate.vue` are **both in audit scope AND dirty**.
|
||||
Read them **as they are on disk**. Do NOT modify them, do NOT revert them, do NOT `git stash`,
|
||||
do NOT `git checkout` them. If the audit finds something in them, write it up as a finding with
|
||||
a note that the file had uncommitted third-party changes at audit time.
|
||||
|
||||
**That agent is committing to these paths live** (`4b5367eb` federation/storage.rs, `bc9a210c`
|
||||
Cloud.vue, `3288a02d` pip.ts all landed after this plan was written). So expect the tree and the
|
||||
log to move underneath you. That is normal and is not your problem to fix. The invariant you owe
|
||||
is narrow and absolute: **no commit you author may contain any of those paths.** Do not rebase,
|
||||
do not reset, do not revert their commits, and re-read a file rather than trusting a stale read
|
||||
if you see it change.
|
||||
|
||||
**Staging rule (CLAUDE.md, non-negotiable):** always `git add <explicit paths>`. Never
|
||||
`git add -A`, never `git add .`, never `git commit -a`.
|
||||
|
||||
## Honesty requirements — apply to all three tasks
|
||||
|
||||
- **Never claim a path is safe without `file:line` evidence.** "I grepped and found nothing" is
|
||||
a valid finding only if you state the grep and the directories it covered.
|
||||
- **Anything that cannot be verified from this environment is `UNVERIFIED`**, listed in the
|
||||
on-node verification checklist, never asserted as checked. Real hardware (`.228` / dev-box)
|
||||
is not reachable from this task.
|
||||
- **Never put a real secret into a document.** Reference the path or variable name
|
||||
(`master_seed.enc`, `STRIPE_SECRET_KEY`), never a value. This includes example/illustrative
|
||||
mnemonics — use `<24 words>` or `word1 … word24` placeholders, never a real wordlist.
|
||||
- Mark research-derived claims that you could not confirm in-tree as `[FROM RESEARCH,
|
||||
NOT RE-VERIFIED]` rather than laundering them into audit findings.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Entropy & seed-generation security audit against the real codebase</name>
|
||||
<files>docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md</files>
|
||||
<read_first>
|
||||
core/archipelago/src/seed.rs (full — the fan-out root),
|
||||
core/archipelago/src/api/rpc/seed_rpc.rs (the network boundary, [ARCHY-4]),
|
||||
core/archipelago/src/container/secrets.rs (DIRTY — read only),
|
||||
neode-ui/src/views/OnboardingSeedGenerate.vue (DIRTY — read only),
|
||||
image-recipe/build-debian-iso.sh + image-recipe/archipelago-scripts/install-to-disk.sh ([ARCHY-3]),
|
||||
docs/adr/ (for the ADR-005 Argon2 parameter cross-check)
|
||||
</read_first>
|
||||
<action>
|
||||
Perform a real audit and write it to `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` (create
|
||||
`docs/security/` first). Do the evidence collection with the bounded pipeline below FIRST, then
|
||||
write — do not write findings before you have their line numbers.
|
||||
|
||||
**Step A — collect evidence (bounded; ~10 bash calls, do not wander outside these paths).**
|
||||
Run these and keep the output as your evidence corpus. `core/*/src` deliberately excludes
|
||||
`core/target/`; exclude `image-recipe/_archived/` from conclusions:
|
||||
|
||||
```
|
||||
grep -rnE 'SmallRng|seed_from_u64|::from_seed\(|rand::rngs::mock|StdRng' core/*/src --include=*.rs
|
||||
grep -rnE 'OsRng|thread_rng|rand::random|getrandom|SystemRandom' core/*/src --include=*.rs
|
||||
grep -rn -B3 -A3 -E 'SystemTime::now|as_nanos|Instant::now' core/*/src --include=*.rs | grep -iE 'key|seed|nonce|salt|token|secret|password|mnemonic'
|
||||
grep -rn -B2 -A2 -E 'Math\.random|getRandomValues|crypto\.subtle|jsbn|SecureRandom\(' neode-ui/src --include=*.ts --include=*.vue
|
||||
grep -rnE '\$RANDOM|/dev/urandom|/dev/random|openssl rand|uuidgen|random\.random|random\.randint|shuf ' scripts/ image-recipe/ --include=*.sh --include=*.py
|
||||
grep -rniE 'random-seed|urandom|jitterentropy|haveged|rng-tools|rngd|crng' image-recipe/ --include=*.sh --include=*.service --include=*.conf
|
||||
find image-recipe -name 'random-seed' -o -name '*.seed'
|
||||
grep -rniE '(info|warn|error|debug|trace)!\(.*(mnemonic|seed|privkey|private_key|passphrase|aezeed)' core/*/src --include=*.rs
|
||||
grep -rn 'derive(Debug' core/archipelago/src/seed.rs core/archipelago/src/identity.rs core/archipelago/src/credentials/store.rs
|
||||
cd core && cargo tree -i rand | head -40 && cargo tree -i getrandom | head -40
|
||||
```
|
||||
Run `cargo audit` only if `command -v cargo-audit` succeeds; if absent, record that as a gap
|
||||
(the research explicitly recommends `cargo audit`/`cargo deny` in CI rather than a snapshot).
|
||||
|
||||
**Step B — trace every secret class.** For each of these, trace from the syscall to the consumer
|
||||
and record `file:line` at every hop. Any hop where the entropy source is a *default* rather than
|
||||
an *argument* is a T1-shaped structural risk and must be called out as such:
|
||||
(1) user Bitcoin/LND wallet seed; (2) LND aezeed (`HKDF(seed, "archipelago/lnd/entropy/v1")`);
|
||||
(3) **fleet release-root signing key** + catalog/manifest signing keys; (4) node identity keys
|
||||
(nostr / FIPS / Reticulum); (5) `container::secrets` `generated_secrets` materialisation
|
||||
(0600/rootless per CLAUDE.md — verify the mode is actually set, don't assume);
|
||||
(6) session tokens + CSRF; (7) onboarding seed generation in the UI.
|
||||
|
||||
**Step C — adjudicate [ARCHY-1] … [ARCHY-4] individually.** Each gets its own subsection headed
|
||||
with the tag and a verdict of exactly `CONFIRMED`, `REFUTED`, or `UNVERIFIED`:
|
||||
- **[ARCHY-1]** — check `core/archipelago/src/seed.rs` around line 92 for the `bip39::Mnemonic::generate(24)`
|
||||
call, then confirm the transitive default by reading the vendored crate at
|
||||
`~/.cargo/registry/src/*/bip39-2.1.0/src/lib.rs` (research cites line 297). State whether the
|
||||
entropy source is chosen at the call site or by the dependency.
|
||||
- **[ARCHY-2]** — verify the `kernel_csprng_ready()` probe near `seed.rs:52-91` really uses the
|
||||
nonblocking flag *as a probe only* and that no key material is drawn from that path.
|
||||
- **[ARCHY-3]** — the highest-unknown item. From `image-recipe/` evidence answer: does the build
|
||||
bake a populated seed file into the image; is there any first-boot regeneration unit; does the
|
||||
image install `jitterentropy-rngd`/`haveged`/`rng-tools`; can onboarding key generation run
|
||||
before the kernel CSPRNG is initialised on freshly-flashed hardware. Answer what the tree can
|
||||
answer with `file:line`. Everything else goes to the on-node checklist as `UNVERIFIED`.
|
||||
- **[ARCHY-4]** — confirm whether the generated mnemonic crosses the JSON-RPC boundary
|
||||
(`seed_rpc.rs` ~line 147), the in-memory TTL and whether it is cleared at verify time
|
||||
(~lines 205-209), and whether the daemon can be served over plaintext HTTP.
|
||||
|
||||
If a research finding does not survive contact with the code, write `REFUTED` and say why
|
||||
plainly. Do not soften it. Also cross-check open question 9: whether `Argon2::default()` in
|
||||
`seed.rs` matches ADR-005's stated 64MB/3-iteration profile — report the actual numbers.
|
||||
|
||||
**Step D — write the document.** Structure:
|
||||
1. Scope + method (directories covered, greps run, what was explicitly excluded and why —
|
||||
name `image-recipe/_archived/` and `core/target/`).
|
||||
2. Executive summary — the honest one-paragraph verdict.
|
||||
3. Findings table, then one subsection per finding. Every finding carries:
|
||||
`Severity` (Critical/High/Medium/Low/Informational) | Evidence (`file:line`) | Exploitability |
|
||||
Blast radius | Concrete remediation.
|
||||
4. `[ARCHY-1]`…`[ARCHY-4]` adjudication (Step C).
|
||||
5. **What we do right** — a real section, giving credit where the code is correct
|
||||
(zeroization, encrypted-at-rest envelope, the CSPRNG-readiness probe, 24-word enforcement,
|
||||
the correct browser RNG call sites — each with `file:line`).
|
||||
6. **On-node verification checklist (UNVERIFIED)** — the discrete checks that need real hardware,
|
||||
each written as a runnable command an operator can paste on `.228`/dev-box, including the
|
||||
cross-node same-ISO collision test from the research.
|
||||
7. Leave a placeholder heading `## Remediation Backlog` — Task 3 fills it.
|
||||
|
||||
Severity must reflect *this* codebase, not the Coldcard incident. Do not inflate: a benign
|
||||
`Math.random()` that only picks which word to quiz is Low or Informational, and the audit should
|
||||
say so and annotate it so the next auditor does not re-derive that it is benign.
|
||||
|
||||
Do not put any secret value in the document (see the secret-shaped-string gate in `verify`).
|
||||
Commit with `git add docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` only.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>AUD=docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md; test -f "$AUD" || { echo "MISSING FILE"; exit 1; }; for t in ARCHY-1 ARCHY-2 ARCHY-3 ARCHY-4 Severity "What we do right" UNVERIFIED; do grep -qi "$t" "$AUD" || { echo "MISSING: $t"; exit 1; }; done; n=$(grep -cE '\.(rs|vue|ts|sh|py|yml|toml):[0-9]+' "$AUD"); [ "$n" -ge 20 ] || { echo "FAIL: only $n file:line evidence refs, need >=20"; exit 1; }; grep -qE '(xprv|xpub|nsec|npub)[A-Za-z0-9]{40,}|BEGIN [A-Z ]*PRIVATE KEY' "$AUD" && { echo "FAIL: secret-shaped string in audit doc"; exit 1; }; echo OK</automated>
|
||||
</verify>
|
||||
<done>`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` exists; every finding has `file:line` evidence and a severity; all four ARCHY tags carry an explicit CONFIRMED/REFUTED/UNVERIFIED verdict; all six mandated secret classes are traced; the on-node checklist exists and is labelled UNVERIFIED; a "What we do right" section exists; no secret value appears anywhere; committed with explicit paths.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: PSBT / watch-only / multisig architecture spec</name>
|
||||
<files>docs/security/PSBT-SIGNING-ARCHITECTURE.md</files>
|
||||
<read_first>
|
||||
docs/hardware-signer-design.md (existing TROPIC01 signer stub — cross-link, do not duplicate),
|
||||
core/archipelago/src/api/rpc/bitcoin.rs (integration point),
|
||||
core/archipelago/src/seed.rs lines ~214-233 (BIP-84 derivation + LND aezeed HKDF),
|
||||
apps/bitcoin-core/manifest.yml + apps/bitcoin-knots/manifest.yml + apps/lnd/manifest.yml (pinned versions),
|
||||
the RESEARCH.md Part C tables (Core RPCs, LND capability matrix, air-gap formats)
|
||||
</read_first>
|
||||
<action>
|
||||
Write `docs/security/PSBT-SIGNING-ARCHITECTURE.md` — a spec, not a tutorial, and not an
|
||||
implementation. Ground every architectural claim in either RESEARCH.md Part C or a `file:line`
|
||||
from this tree; mark anything from neither as `[UNVERIFIED]`.
|
||||
|
||||
Required sections:
|
||||
|
||||
1. **Target architecture.** Watch-only **descriptor** wallet on the node (Core), created with
|
||||
private keys disabled and populated via descriptor import — descriptor-only from day one,
|
||||
because Core 30 removed BDB legacy wallets. Name the actual Core RPCs for each step of the
|
||||
loop (create funded PSBT -> export -> sign offline -> import -> combine -> finalize ->
|
||||
broadcast) and say which RPCs are wallet-scoped vs node-scoped. Recommend driving UI state
|
||||
from `analyzepsbt` (it reports which role must act next) rather than guessing. Record the
|
||||
current versions the node actually runs from the manifests, and flag `bitcoin-knots:latest`
|
||||
as an unpinned tag at odds with ADR-009 (in-scope to flag, out-of-scope to fix).
|
||||
|
||||
2. **Where each step lives.** Map the loop across the three surfaces: Rust orchestrator
|
||||
(`core/archipelago`), `neode-ui`, and the companion app. Be explicit that the BIP-84 private
|
||||
key stays in the daemon's encrypted store and the **xpub only** goes into the Core descriptor
|
||||
wallet — the private key is never imported into Core.
|
||||
|
||||
3. **Tiers.** Tier 1 single-sig with an external hardware signer; Tier 2 `wsh(sortedmulti(k,...))`
|
||||
multisig with BIP-48 paths (`m/48'/coin'/account'/2'` for P2WSH) and descriptor exchange.
|
||||
State why `sortedmulti` over ordered `multi`. Mandate key-origin annotation
|
||||
`[fingerprint/derivation]` — hardware signers cannot locate their key without it. Treat
|
||||
taproot/MuSig2 multisig as future work and say why (unconfirmed 2026 support).
|
||||
|
||||
4. **Air-gapped transport.** Pick a format and justify it against the companion app's *existing*
|
||||
QR scanner and shipped SeedQR capability. Compare BBQr (sequential) vs BC-UR v2 (fountain-coded,
|
||||
order-independent, degrades gracefully in poor light) vs microSD/file. Be honest about QR
|
||||
density: a real multi-input multisig PSBT exceeds single-QR capacity, so animated multi-frame
|
||||
is mandatory and a file fallback must always be offered. Cross-link
|
||||
`docs/hardware-signer-design.md` as the future first-party signer and keep the format choice
|
||||
consistent with it.
|
||||
|
||||
5. **LND — what is and is not achievable.** Reproduce the capability matrix as a decision table:
|
||||
watch-only + remote signer YES; signer fully offline NO (it must accept a live inbound gRPC
|
||||
connection); air-gapping channel/revocation/HTLC keys NO; PSBT channel funding YES; opening a
|
||||
channel with zero LND wallet balance YES; self-broadcasting the funding transaction NEVER
|
||||
(encode that as a hard UI rule — funds can be lost). Name the required xpub accounts and the
|
||||
taproot import gotcha. Then split the whole design into **on-chain balance: genuinely
|
||||
PSBT-protectable** vs **lightning balance: necessarily hot**, and give the exact honest
|
||||
user-facing sentence the UI should use. Any copy implying a routing node's channel keys are
|
||||
cold is misleading — say so.
|
||||
|
||||
6. **Hot wallet as the explicitly-secondary option.** Hard separation of on-chain and Lightning
|
||||
balances in the data model and the UI (never one blended number); server-enforced per-tx and
|
||||
rolling-daily spend limits with anything above forced onto the PSBT path; reuse of the
|
||||
existing at-rest encryption envelope; zeroization; and the explicit cold/warm/hot tiering in
|
||||
the UI. State the design principle from the incident: T1's survivors were the users who took
|
||||
the *optional* extra step, so the safe path must be the **default**, not the option. Also
|
||||
spell out how to nudge toward PSBT without making the hot path feel broken or punitive.
|
||||
|
||||
7. **Migration for existing hot-seed users.** The honest advice implied by the incident: a
|
||||
software fix does not repair an already-generated seed. Specify the sequence — generate a new
|
||||
key, verify the backup and a receive address, send a test transaction, migrate funds, retain
|
||||
the old backup until confirmed — and state clearly which Archipelago users this does and does
|
||||
not apply to based on Task 1's findings (do not over-alarm if Task 1 found no defect; do not
|
||||
under-state if it did).
|
||||
|
||||
8. **Phased rollout.** Concrete, plannable phases with dependencies and what each unlocks. This
|
||||
document is the input to a future `/gsd-plan-phase`, so each phase needs a name, a goal
|
||||
sentence, its dependencies, and 2-5 candidate requirement lines. Suggested shape (adjust with
|
||||
reasoning): descriptor watch-only read path -> PSBT construct/export -> external-signer import
|
||||
and finalize -> air-gap transport -> multisig -> LND remote signing -> hot-wallet limits.
|
||||
Note explicitly which phases need real-hardware verification.
|
||||
|
||||
Do NOT write implementation code. Do NOT add dependencies. Do NOT modify wallet or signing
|
||||
behaviour anywhere in the tree. Commit with `git add docs/security/PSBT-SIGNING-ARCHITECTURE.md`
|
||||
only.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>SPEC=docs/security/PSBT-SIGNING-ARCHITECTURE.md; test -f "$SPEC" || { echo "MISSING FILE"; exit 1; }; for t in walletcreatefundedpsbt analyzepsbt finalizepsbt importdescriptors disable_private_keys sortedmulti BIP-48 remotesigner "necessarily hot" migration Phase hardware-signer-design; do grep -qi "$t" "$SPEC" || { echo "MISSING: $t"; exit 1; }; done; grep -qE '(xprv|xpub|nsec|npub)[A-Za-z0-9]{40,}|BEGIN [A-Z ]*PRIVATE KEY' "$SPEC" && { echo "FAIL: secret-shaped string in spec"; exit 1; }; echo OK</automated>
|
||||
</verify>
|
||||
<done>`docs/security/PSBT-SIGNING-ARCHITECTURE.md` exists covering all eight required sections; the LND section states plainly that channel/revocation/HTLC keys cannot be air-gapped and that the funding transaction must never be self-broadcast; on-chain vs lightning are split into separately-protectable tiers; the hot wallet is framed as explicitly secondary; the rollout is phased with dependencies and candidate requirements; `docs/hardware-signer-design.md` is cross-linked; no code changed; committed with explicit paths.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Remediation backlog, tracker update, and the one gated hardening fix</name>
|
||||
<files>docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md, docs/UNIFIED-TASK-TRACKER.md, core/archipelago/src/seed.rs</files>
|
||||
<behavior>
|
||||
Only if [ARCHY-1] was CONFIRMED in Task 1, the mnemonic-generation path becomes RNG-injectable
|
||||
and the following test exists and passes (it cannot exist before the change, because there is
|
||||
no seam to inject through):
|
||||
- `mnemonic_generation_uses_injected_rng`: driving generation with a deterministic
|
||||
`CryptoRng + RngCore` test RNG produces a stable, asserted 24-word mnemonic (known-answer),
|
||||
proving the passed RNG — not an implicit transitive default — is the one actually consumed.
|
||||
- `mnemonic_generation_is_256_bit`: generated mnemonics are 24 words and two successive
|
||||
productions from the real entropy source differ.
|
||||
Existing seed tests stay green.
|
||||
</behavior>
|
||||
<action>
|
||||
**Part A — remediation backlog (always).** Fill the `## Remediation Backlog` placeholder left in
|
||||
`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`. Prioritise by severity x effort, fastest/most
|
||||
valuable first. Each item needs: the finding it closes, the concrete change, the file(s), an
|
||||
effort estimate, and whether it needs real-hardware verification. Anything that needs a proper
|
||||
phase (PSBT work, ISO first-boot entropy regeneration, confining seed-bearing RPCs to
|
||||
loopback/TLS) is listed here as a backlog item and explicitly **not** implemented in this task.
|
||||
|
||||
**Part B — tracker (always).** Add the resulting open items to `docs/UNIFIED-TASK-TRACKER.md`
|
||||
using that file's existing conventions: `- [ ]` checkboxes, bold lead sentence, indented
|
||||
continuation lines, placed in the correct existing Tier (Tier 0 = quick/mechanical/no blockers,
|
||||
Tier 1 = medium effort/unblocked, etc.) rather than in a new section at the top. Link back to
|
||||
`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` and `docs/security/PSBT-SIGNING-ARCHITECTURE.md`
|
||||
so the tracker stays the single entry point. Use `Edit` for scoped insertions — never rewrite the
|
||||
whole file.
|
||||
|
||||
**Part C — the one gated fix (conditional).** Apply this **only if Task 1 recorded [ARCHY-1] as
|
||||
CONFIRMED**. It is the only code change authorised by this plan.
|
||||
|
||||
Refactor `core/archipelago/src/seed.rs` so the mnemonic-generation call site takes its RNG as an
|
||||
**injected parameter** instead of inheriting a transitive dependency's default: an internal
|
||||
helper that accepts `&mut (impl CryptoRng + RngCore)` and generates a 24-word English mnemonic
|
||||
through the injectable `bip39` entry point, with the production caller passing `OsRng`. Add a
|
||||
comment at the call site pinning the rationale (a bare reference to this audit and to T1 — an
|
||||
explicit source beats an implicit one, and a future `rand`/`bip39` bump must not silently rebind
|
||||
it). Then add the two tests from `<behavior>` to the existing test module (`seed.rs:479`). The
|
||||
known-answer test is what makes this a fix rather than a comment: it is impossible to write
|
||||
against the pre-refactor code because there is no seam to inject through.
|
||||
|
||||
Do **not** bump `bip39` or `rand` versions. Do **not** change the derivation, the word count,
|
||||
the empty-passphrase decision, the at-rest encryption, or anything else in `seed.rs`.
|
||||
|
||||
You MAY additionally fix the `core/archipelago/src/totp.rs` modulo bias (rejection sampling or
|
||||
`SliceRandom::choose` in place of `% charset.len()`) **only if** it is a <=10-line change with its
|
||||
own test and Task 1 confirmed it. Anything beyond these two goes to the backlog. If neither is
|
||||
applied, write a greppable line `ARCHY-1: NOT APPLIED` into the audit doc with the reason.
|
||||
|
||||
Build and test from `core/` (workspace root). If the build hits a `rust-lld: undefined hidden
|
||||
symbol` error, that is incremental-cache corruption — rebuild with `CARGO_INCREMENTAL=0`.
|
||||
|
||||
**Part D — commit and push.** Commit Part A+B together and Part C separately (if applied), always
|
||||
with explicit paths: `git add docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md docs/UNIFIED-TASK-TRACKER.md`
|
||||
then `git add core/archipelago/src/seed.rs`. Never `git add -A`. Then push all three commits with
|
||||
`git push gitea-ai main` (`main` is protected; `gitea-ai` is the push account). If the push fails,
|
||||
report the exact error — do not force-push, do not retarget another remote, do not amend history.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>AUD=docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md; grep -qi "Remediation Backlog" "$AUD" || { echo "FAIL: no remediation backlog"; exit 1; }; grep -q "ENTROPY-SEED-AUDIT-2026-07-31" docs/UNIFIED-TASK-TRACKER.md || { echo "FAIL: tracker missing audit link"; exit 1; }; grep -q "PSBT-SIGNING-ARCHITECTURE" docs/UNIFIED-TASK-TRACKER.md || { echo "FAIL: tracker missing spec link"; exit 1; }; FORBID='container/secrets\.rs|federation/storage\.rs|OnboardingSeedGenerate\.vue|utils/pip\.ts|views/Cloud\.vue'; for c in $(git log --format=%H -10 -- "$AUD" docs/UNIFIED-TASK-TRACKER.md core/archipelago/src/seed.rs core/archipelago/src/totp.rs); do git show --name-only --pretty=format: "$c" | grep -qE "$FORBID" && { echo "FAIL: commit $c mixed in the other agent's files"; exit 1; }; done; if grep -q "ARCHY-1: NOT APPLIED" "$AUD"; then echo "OK (fix deliberately not applied, reason recorded)"; else ( cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago mnemonic_generation_uses_injected_rng 2>&1 | grep -qE 'test result: ok\. [1-9]' ) || { echo "FAIL: injected-rng known-answer test missing or failing"; exit 1; }; ( cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago seed:: 2>&1 | grep -q 'test result: ok' ) || { echo "FAIL: existing seed tests not green"; exit 1; }; echo OK; fi</automated>
|
||||
<human-check>Show the reviewer the full `git diff` of `core/archipelago/src/seed.rs` (and `totp.rs` if touched) BEFORE pushing, plus the output of `git log --oneline -3` and `git show --stat HEAD`. This is master-seed generation code for every new node — get explicit confirmation that the diff is limited to making the RNG explicit + adding tests, and that no derivation, word count, passphrase, or at-rest-encryption behaviour changed. If [ARCHY-1] was not applied, show the recorded reason instead and confirm that is the right call.</human-check>
|
||||
</verify>
|
||||
<done>The audit doc has a prioritised, actionable remediation backlog; `docs/UNIFIED-TASK-TRACKER.md` carries the new open items in its existing tier/checkbox format and links both new docs; either the injectable-RNG fix is applied in `seed.rs` with a known-answer test that passes and could not exist before the change, or `ARCHY-1: NOT APPLIED` plus a reason is recorded; no commit authored by this plan contains any of the other agent's five files; all commits staged by explicit path; the `seed.rs` diff was human-reviewed before push; commits pushed via `gitea-ai`.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| kernel CSPRNG -> userspace key generation | every secret in the system crosses here; a weak or unready source here is unrecoverable |
|
||||
| daemon -> JSON-RPC/websocket client | the master mnemonic currently crosses this boundary ([ARCHY-4]); plaintext HTTP is in use on LAN in places |
|
||||
| build host -> flashed ISO -> N nodes | a single image is written to many nodes; anything entropy-bearing baked in is shared fleet-wide ([ARCHY-3]) |
|
||||
| this task -> committed documentation | audit/spec artifacts are public-facing repo content and could leak secrets or false assurance |
|
||||
| this task -> shared working tree | a concurrent agent's uncommitted work can be clobbered by careless staging |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-UPZ-01 | Information Disclosure | produced audit/spec docs | critical | mitigate | No secret values in any doc — paths/variable names only; enforced by the secret-shaped-string negative gate in Tasks 1 and 2 `<verify>` |
|
||||
| T-UPZ-02 | Repudiation / false assurance | audit findings without evidence | high | mitigate | Every finding requires `file:line`; `>=20` evidence refs enforced by Task 1 `<verify>`; unverifiable items forced into an `UNVERIFIED` on-node checklist |
|
||||
| T-UPZ-03 | Tampering | `core/archipelago/src/seed.rs` (master-seed generation for every node) | critical | mitigate | Fix is gated on [ARCHY-1] being CONFIRMED, bounded to RNG injection + tests, proven by a known-answer test, and human-reviewed via `<human-check>` before push |
|
||||
| T-UPZ-04 | Tampering | concurrent agent's uncommitted work in the shared tree | high | mitigate | Explicit-path staging only (never `git add -A`); dirty files are read-only; Task 3 `<verify>` asserts the five dirty files are still dirty |
|
||||
| T-UPZ-05 | Information Disclosure | master mnemonic over plaintext-HTTP JSON-RPC ([ARCHY-4]) | high | transfer | Audited and written up with remediation (loopback/TLS confinement, shorter TTL); implementation deferred to a proper phase, not done here |
|
||||
| T-UPZ-06 | Spoofing | fleet release-root signing key derived from the same mnemonic | critical | mitigate | Explicitly traced as a first-class secret class in Task 1 Step B — a seed defect forges release manifests fleet-wide, strictly larger blast radius than a wallet |
|
||||
| T-UPZ-07 | Elevation of Privilege | one-ISO-many-nodes entropy correlation ([ARCHY-3]) | high | mitigate | `image-recipe/` evidence grep answers what the tree can answer; the rest becomes a runnable on-node checklist including the cross-node same-ISO collision test |
|
||||
| T-UPZ-SC | Tampering | package installs | low | accept | This plan adds no dependencies and installs nothing; `cargo audit` is invoked read-only if already present |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. Both documents exist under `docs/security/` and pass their automated gates.
|
||||
2. Every audit finding carries `file:line` evidence and a severity; all four ARCHY tags are adjudicated.
|
||||
3. Nothing that requires real hardware is claimed as verified — it appears in the UNVERIFIED on-node checklist instead.
|
||||
4. No secret value appears in any produced document.
|
||||
5. `docs/UNIFIED-TASK-TRACKER.md` carries the new open items in its existing format and links both docs.
|
||||
6. If code changed: `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago seed::` is green and the new known-answer test passes.
|
||||
7. No commit authored by this plan contains any of the concurrent agent's five files (they commit to those paths themselves — that is expected and is not a failure).
|
||||
8. All commits pushed via `gitea-ai`, or the exact push failure reported.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` is an audit a security reviewer would accept: evidence-backed, severity-classified, honest about what it could not verify, and generous where the code is right.
|
||||
- `docs/security/PSBT-SIGNING-ARCHITECTURE.md` is directly plannable — a future `/gsd-plan-phase` can pick up its phase breakdown without re-deriving the architecture.
|
||||
- The remediation backlog is in `docs/UNIFIED-TASK-TRACKER.md`, so the work is not stranded in a doc nobody reads.
|
||||
- At most one small, human-reviewed, test-proven code change landed; everything larger is queued as a backlog item.
|
||||
- Zero disruption to the concurrent agent's uncommitted work.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-SUMMARY.md` when done.
|
||||
</output>
|
||||
+482
@@ -0,0 +1,482 @@
|
||||
# Quick Task 260731-upz — Research
|
||||
|
||||
**Researched:** 2026-07-31
|
||||
**Domain:** Wallet entropy / RNG security; BIP-39 seed generation; PSBT + watch-only + multisig signing architecture
|
||||
**Confidence:** HIGH on Part A (primary vendor + independent researcher sources, dated within 48h), HIGH on Part B (primary docs + direct codebase inspection), MEDIUM-HIGH on Part C (official BIP/Core/LND docs; some 2026-current details noted as unverified)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
### Honesty verdict on Part A: **THE INCIDENT IS REAL AND CONFIRMED.**
|
||||
|
||||
The user's "conkite" is **Coinkite**, and the incident is the **COLDCARD entropy incident**, disclosed **2026-07-30** — i.e. *yesterday*, still actively unfolding as of today. This is not a training-data recollection; it is confirmed by the vendor's own advisory and technical backgrounder, by an independent technical analysis from Block's engineering team, and by on-chain evidence. No fabrication or analogue-substitution was required.
|
||||
|
||||
**One-paragraph version:** A 2021 refactor moved COLDCARD seed generation from the hand-written hardware-TRNG call `ckcc.rng_bytes()` to `ngu.random.bytes()`. Because `libngu`'s guard used `#ifndef MICROPY_HW_ENABLE_RNG` rather than testing the macro's *value*, and COLDCARD's board config defines that macro **as `0`**, the `#error` never fired and the call silently bound to MicroPython's **Yasmarang** software fallback PRNG — seeded from the chip UID's low 32 bits, SysTick, and RTC registers. Effective seed entropy dropped from a nominal 128 bits to **~40 bits on Mk2/Mk3** and **≤2^32 practically on Mk4/Mk5/Q** (a later "fix" reseeded Yasmarang with only **four bytes** of an otherwise-excellent secure-element digest). On 2026-07-30 an attacker swept **594.51 BTC across ~500 transactions in ~15–25 minutes**; the total across the confirmed + provisional sets is **1,082.65 BTC from 1,195 addresses (~$70M)**. Fixed firmware shipped 2026-07-31. Firmware updates **do not repair existing seeds** — affected users must generate new seeds and migrate.
|
||||
|
||||
### Why this matters to Archipelago, specifically
|
||||
|
||||
Archipelago derives **its entire key hierarchy from one 24-word BIP-39 mnemonic** (`core/archipelago/src/seed.rs`): node Ed25519 `did:key`, node Nostr key, FIPS mesh transport key, the **fleet release-root signing key**, per-identity Ed25519 + Nostr keys, the BIP-84 Bitcoin Core wallet, and the LND aezeed entropy. A Coldcard-class entropy defect here would not just drain wallets — it would let an attacker **forge signed release manifests and catalogs for the entire fleet**. The blast radius is strictly larger than a hardware wallet's.
|
||||
|
||||
The good news from direct inspection: Archipelago's entropy path is **structurally sound** — `bip39::Mnemonic::generate(24)` resolves to `rand::thread_rng()`, which in `rand 0.8.5` is a genuine CSPRNG (ChaCha12 seeded from `getrandom(2)`, with fork protection still present in 0.8.x). There is **no Coldcard-class defect present.** But there are five findings worth acting on, three of them structural rather than cryptographic — including the exact *shape* of failure that bit Coinkite (entropy source chosen implicitly by a transitive dependency's default, not stated at the call site).
|
||||
|
||||
**Primary recommendation:** (1) Make the entropy source **explicit and type-pinned** at every key-generation call site and add a regression test that fails if it changes; (2) audit the **ISO/first-boot entropy** story, which is Archipelago's single most plausible real low-entropy exposure given it ships flashable images to a fleet; (3) adopt **PSBT-first** on-chain signing with Bitcoin Core descriptor watch-only wallets, and be honest with users that **LND cannot be meaningfully air-gapped** for a routing node — remote signing moves keys, it does not remove hot-key exposure.
|
||||
|
||||
---
|
||||
|
||||
## 2. Part A — The Incident + Low-Entropy Compromise Catalogue
|
||||
|
||||
### A.1 The COLDCARD entropy incident (2026-07-30 → ongoing)
|
||||
|
||||
#### What was affected
|
||||
|
||||
| Product | Firmware range affected | Fixed in | Effective entropy |
|
||||
|---|---|---|---|
|
||||
| COLDCARD **Mk2 / Mk3** | v4.0.0 / 4.0.1 – 4.1.9 (from 2021-03-17) | **4.2.0** | **~40 bits** [1][3] |
|
||||
| COLDCARD **Mk4 / Mk5** (standard) | v5.0.0 – before 5.6.0 | **5.6.0** | **~72 bits nominal, ≤2^32 practical** [1][3] |
|
||||
| COLDCARD **Mk4 / Mk5** (Edge) | before 6.6.0X | **6.6.0X** | as above |
|
||||
| COLDCARD **Q** (standard) | before 1.5.0Q | **1.5.0Q** | as above |
|
||||
| COLDCARD **Q** (Edge) | before 6.6.0QX | **6.6.0QX** | as above |
|
||||
| COLDCARD **Mk1** | all v3.0.6 | n/a | outside the regression [3] |
|
||||
| **TAPSIGNER / OPENDIME / SATSCARD** | — | — | **unaffected** (different codebases) [1] |
|
||||
|
||||
Coinkite's framing is explicit: *"Exposure depends on the firmware used when a secret was generated, not the device's manufacturing date."* [3]
|
||||
|
||||
#### The defect, precisely
|
||||
|
||||
Three compounding bugs, all documented in primary sources:
|
||||
|
||||
**Bug 1 — the macro guard.** COLDCARD board configs (`stm32/COLDCARD/mpconfigboard.h:76-77`, `stm32/COLDCARD_MK4/mpconfigboard.h:77-78`, `stm32/COLDCARD_Q1/mpconfigboard.h:79-80`) set:
|
||||
|
||||
```c
|
||||
#define MICROPY_HW_ENABLE_RNG (0)
|
||||
```
|
||||
|
||||
...deliberately, because COLDCARD supplies its own hardware-RNG wrapper. But `libngu/ngu/random.c:22-31` guarded with:
|
||||
|
||||
```c
|
||||
#ifndef MICROPY_HW_ENABLE_RNG
|
||||
#error "get a HW TRNG plz"
|
||||
#endif
|
||||
```
|
||||
|
||||
`#ifndef` tests only that the macro *exists*, not that it is *enabled*. Defined-as-zero passes. The build silently bound `ngu.random.bytes()` to MicroPython's software fallback. [3] Coinkite's own postmortem: *"the carefully crafted TRNG code I wrote **was being** used, but just by chance, and only for less important things."* [1]
|
||||
|
||||
**Bug 2 — the Yasmarang fallback's seeding.** MicroPython's fallback PRNG (Yasmarang, never intended for cryptographic use) initialises in `ports/stm32/rng.c` from:
|
||||
|
||||
```c
|
||||
pad = UID_low32 ^ SysTick->VAL;
|
||||
n = RTC->TR; // time register
|
||||
d = RTC->SSR; // sub-second register
|
||||
```
|
||||
|
||||
None of these is a cryptographic entropy source: the MCU UID is a **fixed per-chip identifier** (only its low 32 bits used), SysTick is a predictable counter with ~80,000 distinct values on Mk2/Mk3 (~120,000 on current devices), and the RTC registers are time-correlated and may be effectively static at cold boot. After init, *"every subsequent output is a deterministic state transition"* with no further entropy collection. [3]
|
||||
|
||||
**Bug 3 — the 32-bit reseed (Mk4/Q/Mk5 "mitigation").** Later firmware attempted to reseed from the secure elements (commit `01cb43f7`):
|
||||
|
||||
```python
|
||||
a = callgate.read_rng(1) # 32 bytes from SE1
|
||||
b = callgate.read_rng(2) # 8 bytes from SE2
|
||||
n = ngu.hash.sha256d(a + b)
|
||||
n, = ustruct.unpack('I', n[0:4]) # <-- FOUR BYTES ONLY
|
||||
ngu.random.reseed(n)
|
||||
```
|
||||
|
||||
and `random_reseed()` in C does:
|
||||
|
||||
```c
|
||||
STATIC mp_obj_t random_reseed(mp_obj_t arg) {
|
||||
yasmarang_pad = mp_obj_get_int_truncated(arg); // sets ONE state word
|
||||
return mp_const_none;
|
||||
}
|
||||
```
|
||||
|
||||
Excellent secure-element entropy was **truncated to 32 bits**, fed into a single state word, with no DRBG, no full-state reset, and no periodic reseeding. [3]
|
||||
|
||||
#### Search-space reduction and the exploitation mechanism
|
||||
|
||||
Block's analysis gives the numbers [3]:
|
||||
|
||||
- **Mk2/Mk3 (no reseed):** `2^0` if UID and call history are known; ~`2^16.29` with unknown SysTick; broad ceiling across all timer fields ~`2^40.7`.
|
||||
- **Mk4/Q/Mk5 (32-bit reseed):** at most `2^32`, ~`2^31` average enumeration. The `2^73.27` "raw ceiling" is explicitly disclaimed: *"this is not 73-bit cryptographic security. The timer fields are correlated, may occupy much smaller ranges, and can potentially be observed or reconstructed."*
|
||||
|
||||
Attack loop: an attacker holding any **xpub, address, or public key** enumerates candidate Yasmarang streams offline, derives wallets from each candidate, and uses **the public blockchain as a validation oracle** — stop on address match, then sweep. For paper wallets the oracle is direct.
|
||||
|
||||
The critical generalisable lesson, stated as an inequality [3]:
|
||||
|
||||
```
|
||||
≤ 2^32 candidate RNG outputs
|
||||
↓ SHA256d / PBKDF2 / any deterministic hash
|
||||
≤ 2^32 candidate wallet seeds
|
||||
```
|
||||
|
||||
**Deterministic hashing cannot manufacture entropy.** Wrapping a weak source in SHA256d, HKDF, or PBKDF2-2048 does not widen the output family. This directly rebuts the intuition that "we hash it, so it's fine."
|
||||
|
||||
#### Blast radius beyond seed generation
|
||||
|
||||
The same `ngu.random` stream also fed [3]: paper-wallet secp256k1 private keys, Seed-XOR mask splits, ephemeral ECDH keys for device cloning and USB encryption, Key Teleport temporary credentials, Web2FA TOTP secrets and nonce material, and Secure Notes password generation. **A single compromised RNG contaminates every consumer of it** — a point that applies verbatim to Archipelago's `seed.rs` fan-out.
|
||||
|
||||
#### Timeline
|
||||
|
||||
| Date | Event |
|
||||
|---|---|
|
||||
| May 2018 | MicroPython Yasmarang fallback introduced upstream [1] |
|
||||
| 2021-01-28 | Vulnerable `libngu` STM32 guard introduced [3] |
|
||||
| 2021-03-01 | COLDCARD migrates seed generation to libngu (commit `b18723dd`) [3] |
|
||||
| 2021-03-17 | Firmware v4.0.0 ships the vulnerable path [3] |
|
||||
| 2022-03-11 | 32-bit reseed API added [3] |
|
||||
| 2022-03-14 | First production Mk4 v5.0.0 includes the (insufficient) reseed [3] |
|
||||
| **2026-07-30** | Theft reports surface; Block + researchers investigate; **Coinkite advisory published** [2][3] |
|
||||
| **2026-07-31 09:33 EDT** | Fixed firmware released [5] |
|
||||
| **2026-07-31 12:39 EDT** | Advisory updated: fixed firmware available for **every** affected model/track [2] |
|
||||
|
||||
#### Scope of loss
|
||||
|
||||
- Confirmed sweep: **500 transactions, 594.51 BTC, ~15 minutes** [4]
|
||||
- Provisional reconstructed set: **695 further transactions, 488.14 BTC** [4]
|
||||
- Combined: **1,195 unique source addresses, 1,082.65 BTC**, ~**$70M** within the first 24h [4][5]
|
||||
- 562 BTC consolidated into a single address [5]
|
||||
|
||||
`coldcardentropy.org` provides a **client-side-only** address checker over the 1,195-address dataset (*"Lookup happens locally in your browser. No query is sent or logged"*) and correctly cautions that address matches *"do not prove ownership, cause, or that a wallet is otherwise safe."* [4]
|
||||
|
||||
#### Vendor response and the mitigations that actually held
|
||||
|
||||
- **Dice rolls saved people.** 50–98 fair, private, unrecorded rolls contributed ≥128 bits independently; ≥99 rolls ≈256 bits. Coinkite does not consider such seeds at risk from the RNG issue alone. [2] Users who used the optional dice feature were unknowingly compensating for the hardware failure. Defence-in-depth on entropy paid off literally.
|
||||
- **BIP-39 passphrases help but are not a pass.** Coinkite advises migration even with a strong passphrase. [2]
|
||||
- **Firmware updates do not repair existing seeds.** Update → generate a *new* seed → verify backup and a receive address → send a test transaction → migrate → retain the old backup until confirmed. [1][2]
|
||||
|
||||
#### The AI angle (attributed opinion, not established fact)
|
||||
|
||||
NVK (Coinkite co-founder) claims *"AI-assisted code review can now find latent bugs at a speed that is outpacing even the industry's most seasoned experts,"* suggesting attackers used AI to audit the wallet codebase. [5] **Treat as an unverified attribution** — no source establishes attacker methodology. Its planning-relevant implication is real regardless: **latent entropy bugs that survived five years of human review are now cheap to find at scale.** Age of code is no longer evidence of safety.
|
||||
|
||||
---
|
||||
|
||||
### A.2 Historical low-entropy compromise catalogue — threat checklist
|
||||
|
||||
This is the checklist the follow-on audit should run against Archipelago.
|
||||
|
||||
| # | Incident | Year | Root cause | Search space | Lesson / audit check |
|
||||
|---|---|---|---|---|---|
|
||||
| **T1** | **COLDCARD entropy incident** [1][2][3][4] | 2021–2026 | Build-time macro guard (`#ifndef` vs value test) silently bound seed generation to a non-crypto software PRNG; later 32-bit truncated reseed | 2^40 (Mk3) / ≤2^32 (Mk4+) | **A refactor can silently change your entropy backend.** Pin the RNG at the call site by *type*, not by transitive default. Add a test that asserts the source. |
|
||||
| **T2** | **Milk Sad** — Libbitcoin Explorer `bx seed`, CVE-2023-39910 [6] | 2017–2023 | Mersenne Twister (`mt19937`) seeded with **32 bits of system time** | 2^32 | **Never seed a crypto secret from a clock.** MT19937 is not a CSPRNG; its presence anywhere in a key path is disqualifying. |
|
||||
| **T3** | **Trust Wallet browser extension**, CVE-2023-31290 [7] | 2022–2023 | `mt19937` seeded with a 32-bit value; exploited in the wild Dec 2022 / Mar 2023; >$6M lost | 2^32 (~4B mnemonics, hours on one machine) | Same class as T2 in a *different language/ecosystem*. Audit **every** language in the stack, not just the primary one. |
|
||||
| **T4** | **Randstorm** — BitcoinJS / JSBN `SecureRandom()` [8] | 2011–2015 | JSBN's `SecureRandom()` combined with broken browser `Math.random()` implementations (notably Chrome) | Practically brute-forceable; ~1.4M BTC in weak-key wallets; est. $1.2–2.1B at risk | **Browser RNG is a supply-chain dependency.** Use `crypto.getRandomValues` only; never `Math.random()` in any key path. |
|
||||
| **T5** | **Profanity** vanity-address generator → **Wintermute** [9] | 2022 | 32-bit seed fed to `mt19937_64` to produce a 256-bit key | 2^32; all 7-char vanity addresses crackable in ~50 days on 1,000 GPUs; **$162.5M** loss | Third-party "convenience" key generators are key-material producers. Treat them as such. |
|
||||
| **T6** | **Android `SecureRandom`** [ASSUMED — training knowledge, not re-verified this session] | 2013 | Improper `SecureRandom` initialisation on Android led to repeated ECDSA `k` nonces → private key recovery from two signatures | Direct key recovery | **Nonce reuse in ECDSA is instant key disclosure.** Prefer RFC6979 deterministic nonces. |
|
||||
| **T7** | **Blockchain.info R-value reuse** [ASSUMED — training knowledge, not re-verified this session] | 2014–2015 | Repeated ECDSA `r` values from a faulty RNG path | Direct key recovery | Same as T6; also a *detectable* on-chain signal — duplicate `r` across signatures. |
|
||||
|
||||
**The unifying pattern across all seven:** the failure is almost never in the cryptographic primitive. It is in **where the bits came from** — a clock, a chip ID, a browser, a 32-bit integer, or a default that got silently rebound by a refactor. And in five of seven cases the *effective* search space was exactly or near **2^32**, because 32-bit seeding is the recurring anti-pattern.
|
||||
|
||||
---
|
||||
|
||||
## 3. Part B — Entropy & Seed Generation Audit Checklist
|
||||
|
||||
Actionable and greppable. Findings marked **[ARCHY-n]** are results of direct inspection of this codebase during this research and are pre-verified.
|
||||
|
||||
### B.1 Linux CSPRNG sourcing
|
||||
|
||||
**Correct:**
|
||||
- `getrandom(2)` **without** `GRND_NONBLOCK` — blocks until the pool is initialised, then never blocks again. This is the correct primitive on modern Linux (kernel ≥3.17; behaviour improved in 5.6+ and again in 5.17/5.18 where `/dev/random` and `/dev/urandom` converge). Since kernel 5.6 the `getrandom()` blocking path is the only one that guarantees an initialised pool.
|
||||
- `/dev/urandom` — acceptable **only after** the pool is known-initialised. It **never blocks**, including before initialisation, which is exactly the early-boot hazard.
|
||||
- `GRND_NONBLOCK` is correct **only** for *probing* readiness (returns `EAGAIN` when unseeded), never for drawing key material.
|
||||
|
||||
**Dangerous:**
|
||||
- Reading `/dev/urandom` during early boot / initramfs / first-boot provisioning.
|
||||
- Any userspace entropy "mixing" that *replaces* rather than *supplements* the kernel CSPRNG.
|
||||
- Trusting `RDRAND`/`RDSEED` as a sole source. Current posture: fine as **one input** into the kernel pool (which is what Linux does), never as the exclusive source — the microarchitectural trust argument has not improved.
|
||||
|
||||
**The image/clone problem — this is Archipelago's highest-risk real exposure:**
|
||||
Archipelago **ships flashable ISOs to a fleet**. Three distinct hazards:
|
||||
1. **A baked `random-seed` file.** If the ISO or the built rootfs contains a populated `/var/lib/systemd/random-seed` (or `/var/lib/urandom/random-seed`), **every node flashed from that image starts from the same credit**. Must be verified absent (or zero-length) in the image.
|
||||
2. **Early-boot seed generation on freshly-flashed hardware.** Onboarding generates the master seed very early, potentially before the pool has accumulated much. `getrandom(2)` blocking makes this *safe but slow*; the failure mode is a hang, not a weak key — which is the correct trade.
|
||||
3. **VM / container clones.** If any node image is ever cloned post-first-boot, the cloned pool state is shared.
|
||||
|
||||
**Mitigations to spec:** `jitterentropy-rngd` (kernel ≥5.6 also has an in-kernel jitter source) or `haveged` in the image for headless/low-peripheral hardware; explicit removal of any seed file at image build; a first-boot unit that regenerates the seed file; `RNDADDENTROPY` (via `rngd`) only where a *trusted* hardware source exists.
|
||||
|
||||
**Audit commands:**
|
||||
```bash
|
||||
# Is a seed file baked into the image?
|
||||
find image-recipe/ -name "random-seed" -o -name "*.seed"
|
||||
# On a freshly-flashed node, before any key generation:
|
||||
cat /proc/sys/kernel/random/entropy_avail
|
||||
systemd-analyze blame | grep -i random
|
||||
journalctl -b | grep -i "crng init\|random: " # look for "crng init done" timestamp
|
||||
```
|
||||
Correlate the `crng init done` timestamp against the timestamp of seed generation. **[ARCHY-3]** below.
|
||||
|
||||
### B.2 Rust specifics
|
||||
|
||||
**Grep for these — dangerous in a key path:**
|
||||
```
|
||||
rand::random # CSPRNG-backed in rand 0.8, but source is implicit
|
||||
SmallRng # NOT cryptographic — disqualifying
|
||||
StdRng::seed_from_u64 # deterministic from 64 bits — disqualifying
|
||||
::from_seed( # check what the seed is
|
||||
rand::rngs::mock
|
||||
SystemTime::now() # near any key/nonce/salt generation
|
||||
.as_nanos() # ditto
|
||||
```
|
||||
|
||||
**Grep for these — correct:**
|
||||
```
|
||||
rand::rngs::OsRng # direct getrandom(2); no userspace state
|
||||
getrandom::getrandom
|
||||
ring::rand::SystemRandom
|
||||
rand::thread_rng # a CSPRNG, but see the nuance below
|
||||
```
|
||||
|
||||
**`rand::thread_rng()` — the nuance that matters here.** In `rand 0.8.x`, `ThreadRng` is `ReseedingRng<ChaCha12Core, OsRng>`: seeded from `getrandom(2)`, reseeded every 64 KiB, implements `CryptoRng`. It **is** cryptographically acceptable. Two version-sensitive caveats [10]:
|
||||
- **Fork protection was removed in `rand 0.9.0` (2025-01-27).** The changelog: *"Remove fork-protection from `ReseedingRng` and `ThreadRng`. Instead, it is recommended to call `ThreadRng::reseed` on fork."* Archipelago is on **`rand 0.8.5`, which still has fork protection** — but a future bump to 0.9/0.10 silently removes it. Archipelago's orchestrator forks/spawns constantly.
|
||||
- **`rand 0.9.1` (2025-04-17)** added an explicit upstream policy statement: *"rand is not a crypto library."* [10] Take the maintainers at their word: for key material, prefer `OsRng` (renamed `SysRng` in `rand 0.10.0`, 2026-02-08 [10]).
|
||||
|
||||
**RustSec status:** the only directly relevant advisory found is **RUSTSEC-2021-0023** (`rand_core` 0.6.0–0.6.1: `le::read_u32_into` / `read_u64_into` under-fill the destination buffer; category *crypto-failure*) [11]. No current advisory found against `rand 0.8.5`, `getrandom`, `bip39`, `rust-bitcoin`, or `bdk`. **The audit should run `cargo audit` / `cargo deny` in CI rather than relying on this snapshot.** Bumping `rand` to 0.9+ requires the explicit fork-reseed treatment above.
|
||||
|
||||
**secp256k1 nonces:** prefer **RFC6979 deterministic nonces** (`sign_ecdsa` in `rust-secp256k1` is RFC6979 by default) over randomised nonces. This eliminates the T6/T7 class entirely. If you use randomised or auxiliary-randomness variants (`sign_ecdsa_with_noncedata`, BIP-340 aux rand), the randomness must come from `OsRng`.
|
||||
|
||||
**Zeroization:** `zeroize` / `ZeroizeOnDrop` on every seed, mnemonic, and derived-key type. Watch for the classic escapes: `String`/`Vec` reallocation leaves copies behind; `format!`/`to_string()` on secret types; `#[derive(Debug)]` on a struct holding key bytes; `Clone` on secret types.
|
||||
|
||||
#### Archipelago findings (direct inspection)
|
||||
|
||||
**[ARCHY-1] — STRUCTURAL, the Coldcard-shaped one. `core/archipelago/src/seed.rs:92`**
|
||||
|
||||
```rust
|
||||
let mnemonic = bip39::Mnemonic::generate(24)
|
||||
```
|
||||
|
||||
In `bip39 2.1.0` this resolves through `generate` → `generate_in` → `generate_in_with(&mut rand::thread_rng(), language, word_count)` (verified by reading `~/.cargo/registry/.../bip39-2.1.0/src/lib.rs:297`). So **the entropy source for Archipelago's entire key hierarchy — including the fleet release-root signing key — is chosen by a transitive dependency's default, not stated at the call site.**
|
||||
|
||||
*This is not a vulnerability today.* `thread_rng()` in 0.8.5 is a CSPRNG with fork protection. But it is **precisely the structural pattern that produced T1**: a call whose entropy backend is determined by build/dependency configuration rather than by the calling code. A `bip39` minor bump, a `rand` major bump, or a feature-flag change could rebind it without a compile error.
|
||||
|
||||
Recommended (planning input, not applied here):
|
||||
```rust
|
||||
use rand::rngs::OsRng;
|
||||
let mnemonic = bip39::Mnemonic::generate_in_with(
|
||||
&mut OsRng, bip39::Language::English, 24
|
||||
)?;
|
||||
```
|
||||
plus a regression test asserting 256-bit entropy and a comment pinning the rationale. Note `bip39` is pinned `=2.1.0` while 2.2.2 is current — review its changelog before bumping.
|
||||
|
||||
**[ARCHY-2] — GOOD, keep. `core/archipelago/src/seed.rs:52-91`**
|
||||
The `kernel_csprng_ready()` probe uses `GRND_NONBLOCK` correctly *as a probe only* and logs a `warn!` when the pool is uninitialised. The doc comment correctly reasons that `getrandom(2)` blocks so a seed can never be drawn from an unseeded pool. This is exactly right and better than most implementations. Two hardening notes: (a) the invariant depends on `getrandom` (the crate) using the blocking syscall — worth an explicit test rather than a comment; (b) consider elevating the warn to a **structured event persisted to disk**, so a post-hoc audit of any node can answer "was the pool ready when this seed was born?" — the question Coldcard owners cannot answer today.
|
||||
|
||||
**[ARCHY-3] — HIGH PRIORITY, unverified, ISO-specific.** Nothing in this research verified whether the built ISO ships a populated `/var/lib/systemd/random-seed`, nor whether `crng init done` reliably precedes onboarding seed generation on freshly-flashed hardware. Given Archipelago ships a *single image to many nodes*, this is the most plausible route to a real cross-node entropy correlation. Must be checked on real hardware (see Open Questions).
|
||||
|
||||
**[ARCHY-4] — MEDIUM, seed crosses the network boundary. `core/archipelago/src/api/rpc/seed_rpc.rs:147`**
|
||||
The generated mnemonic is returned to the web client as `words: Vec<String>` over JSON-RPC, and held server-side in memory under a 10-minute TTL (`MNEMONIC_TTL`), deliberately not cleared at verify time (`seed_rpc.rs:205-209`, with a documented rationale about client aborts). Archipelago is **served over plain HTTP on LAN in places** (memory: `.116` runs nginx :80 with `ARCHY_SCHEME=http`). A 24-word master mnemonic that unlocks the release-root signing key traversing plaintext HTTP on a shared LAN is a genuine exposure — independent of RNG quality. Mitigations to spec: confine seed-bearing RPCs to loopback/onboarding-only, force TLS for those methods, shrink the TTL, and treat the in-memory hold as a deliberate, documented, time-boxed risk.
|
||||
|
||||
**[ARCHY-5] — LOW, modulo bias. `core/archipelago/src/totp.rs:305`**
|
||||
```rust
|
||||
let idx = (rand::random::<u8>() as usize) % charset.len();
|
||||
```
|
||||
Classic modulo bias whenever `charset.len()` does not divide 256 — a small, uniform-distribution defect in generated passwords/backup codes, not a catastrophic one. Fix with rejection sampling or `rand::seq::SliceRandom::choose`.
|
||||
|
||||
**Also noted (no action required):** `storage_crypto.rs:39` and `credentials/store.rs:69` draw 96-bit ChaCha20-Poly1305 nonces via `rand::random()`. CSPRNG-backed and fine; be aware of the random-nonce birthday bound (~2^32 messages per key) if either key becomes long-lived and high-volume.
|
||||
|
||||
### B.3 JS / TS / browser specifics
|
||||
|
||||
**Dangerous — grep:** `Math.random`, `Date.now()` near key generation, `new Date().getTime()`, `jsbn`, `SecureRandom(` (the T4 signature), any `bip39`/`bitcoinjs-lib` mnemonic generation in the browser.
|
||||
|
||||
**Correct:** `crypto.getRandomValues(new Uint8Array(n))` (browser), `crypto.randomBytes(n)` (Node), `crypto.webcrypto.getRandomValues` (Node ≥15).
|
||||
|
||||
**The secure-context fact that matters for Archipelago** [12]: `Crypto.getRandomValues()` is **the only member of the `Crypto` interface usable from an insecure context** — it works over plain `http://`. `crypto.subtle` / `SubtleCrypto` **requires a secure context** and will be `undefined` over plain HTTP. Since Archipelago serves the UI over plain HTTP on LAN in places, any code path that reaches for `crypto.subtle` will fail there while `getRandomValues` keeps working. Max 65,536 bytes per `getRandomValues` call (`QuotaExceededError` beyond).
|
||||
|
||||
**Archipelago frontend findings (direct inspection):**
|
||||
- ✅ `neode-ui/src/views/OnboardingVerify.vue:107` and `neode-ui/src/views/web5/Web5.vue:185` use `crypto.getRandomValues` — correct, and correct under plain HTTP.
|
||||
- ⚠️ `neode-ui/src/views/OnboardingSeedVerify.vue:159` uses `Math.floor(Math.random() * max)` to choose which mnemonic word indices to quiz. **Not key material** — the indices only select a UX challenge; an attacker who could predict them still learns nothing. **Low severity**, but it is a `Math.random()` call inside a *seed-handling view*, which is the kind of thing an auditor should either fix or annotate so the next auditor doesn't have to re-derive that it's benign.
|
||||
- ✅ `rpc-client.ts` (retry jitter), `Login.vue:317` (progress bar), `BootScreen.vue` (starfield) — `Math.random()` is correct here; non-security.
|
||||
|
||||
### B.4 BIP-39 correctness
|
||||
|
||||
- **Entropy lengths:** 128 bits → 12 words; 256 bits → 24 words. Archipelago uses 24/256 and enforces `word_count != 24` rejection on restore (`seed.rs:112`) — good.
|
||||
- **Checksum:** first `ENT/32` bits of `SHA256(entropy)` appended. A valid checksum proves *format*, **not entropy quality** — it would have passed cleanly on every drained Coldcard.
|
||||
- **Seed derivation:** `PBKDF2-HMAC-SHA512`, 2048 rounds, salt = `"mnemonic" + passphrase`. Archipelago uses an **empty passphrase** (`seed.rs:100`), which is a defensible product decision but removes the second factor that partially protected some Coldcard users. Worth an explicit decision record.
|
||||
- **Hazards to check:** brain wallets (never); user-supplied dice entropy (must be *added to*, never *replace*, system entropy — and note that dice were exactly what saved Coldcard users); wordlist normalisation (NFKD, and language must be pinned); any "compress the mnemonic to a short code" feature.
|
||||
- **The T1 inequality, restated as an audit rule:** *if `N` bits enter the KDF, at most `2^N` seeds can exit it.* Count the bits at the **source**, never at the output.
|
||||
|
||||
### B.5 Memory and at-rest handling
|
||||
|
||||
- `zeroize` / `ZeroizeOnDrop` on all seed types — Archipelago's `MasterSeed` does this (`seed.rs:47-50`). ✅
|
||||
- Never log seed material at any level — `seed.rs:18` states this as an invariant; the audit should *verify* it by grepping for `mnemonic` / `seed` inside `tracing::`, `format!`, `Display`/`Debug` impls, and error strings (a mnemonic embedded in an `anyhow` context string will reach the log).
|
||||
- Avoid swap for the daemon: `MemoryDenyWriteExecute`, and consider `mlock`/`memfd` for the in-memory pending mnemonic; or disable swap on nodes.
|
||||
- File permissions: `master_seed.enc` / `lnd_aezeed.enc` must be `0600`, owned by the service user. Archipelago already encrypts at rest with **Argon2 + ChaCha20-Poly1305** (`seed.rs:238-260`, salt/nonce from `OsRng`). ✅ — note `Argon2::default()` parameters vs ADR-005's stated 64MB/3-iteration profile; worth confirming they match.
|
||||
- **The seed should ideally never cross the RPC/websocket boundary at all** — see [ARCHY-4].
|
||||
|
||||
### B.6 Seed display and QR
|
||||
|
||||
Archipelago already ships SeedQR (Passport-Prime-compatible; memory notes LND aezeed is text-only by design). Audit items: no seed in clipboard by default; screenshot-hostile display where the platform permits; SeedQR rendered client-side from data already on screen rather than fetched as an image; the QR must never be logged or cached; and the companion app's scanner must not persist scanned frames.
|
||||
|
||||
### B.7 Verification techniques an auditor can run
|
||||
|
||||
1. **Call-graph trace.** For every secret, trace from the syscall to the consumer. Any hop where the source is a *default* rather than an *argument* is a T1-shaped risk.
|
||||
2. **Dependency-default sweep.** `cargo tree -i rand` / `-i getrandom`; for each crate that generates key material, read its `generate()` to find which RNG it defaults to. This is how [ARCHY-1] was found and is the single highest-yield technique for this bug class.
|
||||
3. **`cargo audit` / `cargo deny` in CI** — do not rely on a point-in-time RustSec snapshot.
|
||||
4. **Boot-order evidence.** Correlate `crng init done` from `journalctl -b` against the seed-generation timestamp on freshly-flashed hardware.
|
||||
5. **Cross-node collision test.** Flash N nodes from the same ISO, generate a seed on each without user interaction, and confirm all N differ *and* that their first 64 bytes show no structure. This is the empirical test that would have caught T1.
|
||||
6. **NIST SP 800-90B-style spot checks** on the *raw source* (not the KDF output) — min-entropy estimation, repetition-count and adaptive-proportion health tests. Note these test the source, and a broken source wrapped in SHA256 will pass output-side tests (Yasmarang output would pass most statistical suites; that is why they didn't catch it).
|
||||
7. **On-chain nonce check** for any ECDSA signing: scan for duplicate `r` values.
|
||||
|
||||
---
|
||||
|
||||
## 4. Part C — PSBT / Watch-Only / Multisig Landscape + LND Capability Matrix
|
||||
|
||||
### C.1 PSBT (BIP-174 / BIP-370)
|
||||
|
||||
PSBT is the interchange format for not-yet-fully-signed transactions plus the metadata signers need. [13]
|
||||
|
||||
**Core RPCs and the loop:**
|
||||
|
||||
| RPC | Type | Role |
|
||||
|---|---|---|
|
||||
| `walletcreatefundedpsbt` | wallet | Create PSBT with inputs/outputs, auto-add inputs + change, attach metadata |
|
||||
| `walletprocesspsbt` | wallet | Add UTXO/key/script data, optionally sign, finalize where possible |
|
||||
| `descriptorprocesspsbt` | **node** | Process a PSBT against a supplied descriptor list — **no wallet required** |
|
||||
| `utxoupdatepsbt` | node | Fill in UTXO data from the node's UTXO set |
|
||||
| `analyzepsbt` | node | Report what each input still needs and the next required role |
|
||||
| `joinpsbts` | node | Merge distinct PSBTs into one transaction |
|
||||
| `combinepsbt` | node | Merge signatures for the **same** transaction from multiple signers |
|
||||
| `finalizepsbt` | node | Produce the network-serialized tx |
|
||||
| `sendrawtransaction` | node | Broadcast |
|
||||
|
||||
**Canonical flow:** `walletcreatefundedpsbt` (watch-only) → export → sign offline → import → `combinepsbt` (multisig) → `finalizepsbt` → `sendrawtransaction`. `analyzepsbt` is the right thing to drive UI state from — it tells you literally which role must act next, so the UI never has to guess.
|
||||
|
||||
**PSBTv2 / BIP-370** removes the fixed `PSBT_GLOBAL_UNSIGNED_TX` field and distributes transaction data into per-input/per-output fields, enabling interactive construction. **PSBTv2 support has been merged into Bitcoin Core** [14]. **[UNVERIFIED]** — I did not confirm which released Core version first exposes PSBTv2 at the RPC surface, nor its current hardware-signer support breadth. Treat **PSBTv1 as the interop baseline** and PSBTv2 as opportunistic.
|
||||
|
||||
**Bitcoin Core 30.0 is a hard constraint:** BDB **legacy wallets can no longer be created or loaded** (migrate via `migratewallet`); 11 legacy RPCs removed. [14] **Archipelago runs `bitcoin:28.4` and `bitcoin-knots:latest`** (`apps/bitcoin-core/manifest.yml`, `apps/bitcoin-knots/manifest.yml`). Any PSBT work should be built **descriptor-only** from day one — do not add anything that depends on legacy wallets, and note that `bitcoin-knots:latest` is an unpinned tag, which is separately at odds with ADR-009's pinned-tag mandate.
|
||||
|
||||
### C.2 Watch-only via descriptors (BIP-380–386)
|
||||
|
||||
- `importdescriptors` imports output descriptors; a wallet imported with **public** descriptors only (`xpub`/`tpub`, no private keys) **structurally cannot sign** — this is the correct way to build an unsignable wallet, far better than any flag.
|
||||
- Key origin annotation `[fingerprint/derivation]` (e.g. `wpkh([d34db33f/84h/0h/0h]xpub.../0/*)`) is **mandatory** for hardware signers to locate their own key.
|
||||
- Every descriptor carries a checksum; Core rejects descriptors with a wrong one.
|
||||
- Create with `createwallet ... disable_private_keys=true`, then `importdescriptors`.
|
||||
|
||||
**Archipelago integration point:** `core/archipelago/src/api/rpc/bitcoin.rs` already derives a BIP-84 `m/84'/0'/0'` key from the master seed (`seed.rs:214-224`). The PSBT-first design should export the **xpub at that path** into a Core descriptor watch-only wallet and keep the private key in the daemon's encrypted store, used only to sign PSBTs — never imported into Core.
|
||||
|
||||
### C.3 Multisig
|
||||
|
||||
- **`wsh(sortedmulti(k, xpub1/…, xpub2/…, xpub3/…))`** is the standard. `sortedmulti` (BIP-67) lexicographically sorts keys in the resulting script, so **the wallet can be recreated without preserving xpub order** — a real operational win. Use `sortedmulti` unless you have a specific reason for ordered `multi`.
|
||||
- Bitcoin Core ships a canonical worked example: `doc/multisig-tutorial.md` and the functional test `test/functional/wallet_multisig_descriptor_psbt.py` — the latter is the best copyable reference for the exact RPC sequence. [15]
|
||||
- **BIP-48** derivation for multisig accounts: `m/48'/coin'/account'/script_type'` (`2'` = P2WSH). Use it; every coordinator expects it.
|
||||
- **Taproot / MuSig2 multisig:** `tr(...)` descriptors exist; **[UNVERIFIED]** — I did not confirm the 2026 state of MuSig2 key-aggregation support in Bitcoin Core's descriptor wallet or in hardware signers. **Ship `wsh(sortedmulti(...))`; treat taproot multisig as future work.**
|
||||
- **Reference implementations worth copying:** Sparrow (best all-round coordinator UX; auto-detects BBQr vs UR by connected device), Nunchuk (mobile multisig + key-sharing UX), Caravan (browser coordinator, now with BC-UR v2 QR support), Specter (Core-native). Coinkite publishes a Core-specific 2-of-2 descriptor guide. [16]
|
||||
|
||||
### C.4 Air-gapped transport formats
|
||||
|
||||
| Format | Origin | Mechanism | Notes |
|
||||
|---|---|---|---|
|
||||
| **BBQr** | Coinkite (`bbqr.org`) | Data split across sequential QR frames; receiver accumulates | Simpler; needs the frames it missed. Coldcard's native format. [17] |
|
||||
| **UR / BC-UR (v2)** | Blockchain Commons | **Fountain codes** (rateless erasure) — any sufficient subset of frames reconstructs the payload, order-independent | **More robust in noisy scanning.** Preferred if implementing one. [17][18] |
|
||||
| **SeedQR** | SeedSigner | Static QR of mnemonic word indices | Seed transport, not PSBT. Archipelago already ships this. |
|
||||
| **NFC** | Coinkite | Tapsigner / Satscard | Card products; unaffected by T1. |
|
||||
| **microSD / file** | universal | `.psbt` file exchange | Highest capacity, no density limits, slowest UX. **Most reliable for large PSBTs.** |
|
||||
|
||||
**Device support (from sources; some entries incomplete):** Coldcard → BBQr (native) + microSD + NFC; Foundation Passport and Keystone → UR; SeedSigner → BC-UR v2 [17][18]. **[UNVERIFIED]** — Jade, Krux, BitBox, Ledger, Trezor QR/format support was not confirmed this session.
|
||||
|
||||
**Density reality:** a QR maxes out around ~2,953 bytes at the largest version with lowest error correction, and far less at practical camera-scannable densities. A multi-input multisig PSBT routinely exceeds that, so **animated multi-frame is mandatory, not optional**, and microSD should always be offered as the fallback.
|
||||
|
||||
**Archipelago integration point:** the companion mobile app already has a QR scanner and SeedQR support. Adding **UR (fountain-coded)** for PSBT is the highest-leverage air-gap feature — it degrades gracefully in poor lighting, which is where BBQr's sequential model frustrates users.
|
||||
|
||||
### C.5 LND capability matrix — be honest with users
|
||||
|
||||
**Remote signing** splits `lnd` into a watch-only instance (xpubs only, internet-facing) and a signer instance (private keys, reachable only via a single inbound gRPC connection). [19]
|
||||
|
||||
Signer config:
|
||||
```ini
|
||||
[Application Options]
|
||||
nolisten=true
|
||||
nobootstrap=true
|
||||
rpclisten=10019
|
||||
[bitcoin]
|
||||
bitcoin.active=true
|
||||
bitcoin.mainnet=true
|
||||
bitcoin.node=nochainbackend
|
||||
```
|
||||
Watch-only config:
|
||||
```ini
|
||||
[remotesigner]
|
||||
remotesigner.enable=true
|
||||
remotesigner.rpchost=<signer_host:port>
|
||||
remotesigner.tlscertpath=<signer tls.cert>
|
||||
remotesigner.macaroonpath=<signer custom macaroon>
|
||||
```
|
||||
Setup: `lncli wallet accounts list > accounts-signer.json` on the signer → `lncli createwatchonly accounts-signer.json` on the watch-only node. Minimal signer macaroon: `lncli bakemacaroon --save_to signer.custom.macaroon message:write signer:generate address:read onchain:write`. Migration of an existing node: `remotesigner.migrate-wallet-to-watch-only=true` (purges private key material in place). [19]
|
||||
|
||||
Required xpub accounts at level-3 derivation: purpose **49** (NP2WKH), **84** (P2WKH), **86** (P2TR), and **1017** accounts 0–255 (node identity, channels, watchtower, HTLCs). Taproot requires v0.15.3-beta+ and a manual `lncli wallet accounts import --address_type p2tr <xpub> default` on upgrade, else `"account 0 not found"`. [19]
|
||||
|
||||
| Capability | Possible with LND today? | Detail |
|
||||
|---|---|---|
|
||||
| Watch-only `lnd` + separate signer | ✅ Yes | `remotesigner.*`; signer needs no chain backend (`bitcoin.node=nochainbackend`) [19] |
|
||||
| Signer fully offline | ❌ **No** | Signer must accept a **live inbound gRPC connection**. "Offline except one connection" ≠ air-gapped. [19] |
|
||||
| Air-gap channel/revocation/HTLC keys | ❌ **No** | These live in the signer and must sign **on demand, at protocol speed**. A routing node cannot tolerate human-in-the-loop signing. This is the hard limit. [19] |
|
||||
| PSBT funding of channels | ✅ Yes | `lncli openchannel --psbt` interactive flow; `PsbtShim` via `FundingStateStep`; batch by passing the returned PSBT as `base_psbt` [20] |
|
||||
| Open channels with zero LND wallet balance | ✅ Yes | The `--psbt` flow explicitly supports funding from an external wallet [20] |
|
||||
| Self-broadcast of the funding tx | ❌ **Never** | *"Do not publish the finished transaction by yourself or with another tool — lnd must publish it in the proper funding flow order or the funds can be lost."* [20] **Hard rule; encode it in the UI.** |
|
||||
| Sign arbitrary messages / on-chain txs externally | ✅ Yes | `signrpc` / `walletrpc` (`signer:generate`, `onchain:write`) [19] |
|
||||
| aezeed vs BIP-39 | aezeed is LND's own 24-word format | Archipelago sidesteps the mismatch by deriving 16 bytes of **aezeed entropy** from the BIP-39 master seed via `HKDF(seed, "archipelago/lnd/entropy/v1")` (`seed.rs:226-233`) — so the LND wallet is reproducible from the one mnemonic. Good design; document that the aezeed itself is text-only (no SeedQR) by design. |
|
||||
| Move private keys between instances post-init | ❌ Not supported [19] |
|
||||
| Add accounts dynamically without wallet reconstruction | ❌ Not supported [19] |
|
||||
|
||||
**The honest user-facing statement:** *A Lightning routing node's channel keys are necessarily hot. Remote signing relocates them to a hardened machine; it does not make them cold. Only your on-chain balance can be genuinely PSBT-protected.* Any UI that implies otherwise is misleading, and this incident is a good reason to be conservative in that copy.
|
||||
|
||||
### C.6 Hot wallet as a responsible secondary
|
||||
|
||||
If a hot wallet ships alongside a PSBT-first design:
|
||||
1. **Hard separation of on-chain and Lightning balances** in the data model and in the UI — never one "balance" number.
|
||||
2. **Spend limits** on the hot path (per-tx and rolling daily), enforced **server-side**, with anything above the limit forced onto the PSBT path.
|
||||
3. **Encrypted at rest** with the existing Argon2 + ChaCha20-Poly1305 envelope; key material never in the UI, never over RPC.
|
||||
4. **Explicit tiering in the UI:** cold (PSBT/watch-only) → warm (hot on-chain, limited) → hot (Lightning, unavoidably). Name the tradeoff rather than hiding it.
|
||||
5. **Default to the safe path.** T1's survivors were the users who took the *optional* extra step (dice rolls). Design so the safe path is the default, not the option.
|
||||
|
||||
---
|
||||
|
||||
## 5. Open Questions / Could Not Verify
|
||||
|
||||
1. **[ARCHY-3] ISO entropy** — Does the built ISO ship a populated `/var/lib/systemd/random-seed`? Does `crng init done` precede onboarding seed generation on freshly-flashed hardware? Does the image include `jitterentropy-rngd`/`haveged`? **Must be checked on real hardware; not answerable from this environment.** Highest-priority unknown.
|
||||
2. **Cross-node seed collision test** — never run to my knowledge. The N-node same-ISO test in B.7(5) is cheap and is the empirical proof.
|
||||
3. **PSBTv2 in released Core** — merged [14], but the first release exposing it at the RPC surface, and its hardware-signer support breadth, were not confirmed.
|
||||
4. **Taproot / MuSig2 descriptor multisig** — 2026 state in Core and hardware signers not confirmed. Recommendation stands: ship `wsh(sortedmulti(...))`.
|
||||
5. **Hardware-signer format matrix** — Jade, Krux, BitBox, Ledger, Trezor QR/UR/BBQr support unconfirmed.
|
||||
6. **CVE assignment for the Coldcard incident** — no CVE ID found in any source as of 2026-07-31. Given disclosure was <48h ago, one may not exist yet. Searched: "Coldcard entropy bug CVE 2026 advisory MICROPY_HW_ENABLE_RNG".
|
||||
7. **T6 (Android SecureRandom 2013) and T7 (Blockchain.info R-value reuse)** — included from training knowledge, marked `[ASSUMED]`; not re-verified with live sources this session. Their *lesson* (RFC6979) is independently well-established.
|
||||
8. **AI-assisted discovery of the Coldcard bug** — NVK's attribution [5] is an opinion, not established fact. No source establishes attacker methodology.
|
||||
9. **Argon2 parameters** — `seed.rs` uses `Argon2::default()`; ADR-005 specifies 64MB / 3 iterations. Whether the default matches was not confirmed.
|
||||
10. **`bitcoin-knots:latest`** — unpinned image tag in `apps/bitcoin-knots/manifest.yml`, which appears to conflict with ADR-009's pinned-tag mandate. Out of scope here; flagged for the follow-on.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sources
|
||||
|
||||
All accessed **2026-07-31**.
|
||||
|
||||
**Primary — the incident**
|
||||
1. Coinkite, *"Technical Deep Dive into the Entropy Issue"* — https://blog.coinkite.com/entropy-technical-backgrounder/ (vendor postmortem; `ckcc.rng_bytes()` → `ngu.random.bytes()`, `random.c:22-31` guard, entropy figures, timeline)
|
||||
2. Coinkite, *"Coldcard Security Advisory"* — https://blog.coinkite.com/coldcard-mk3-seed-generation-warning/ (published 2026-07-30; updated 2026-07-31 12:39 EDT; affected/fixed versions, dice exception, user actions)
|
||||
3. Block Engineering, *"Predictable RNG Fallback and 32-Bit Reseed in COLDCARD Firmware"* — https://engineering.block.xyz/blog/predictable-rng-fallback-and-32-bit-reseed-in-coldcard-firmware (**deepest technical source**: file/line refs, Yasmarang seeding, `random_reseed()`, search-space math, commit hashes, timeline)
|
||||
4. *"COLDCARD Entropy Incident — Address Check and Evidence"* — https://coldcardentropy.org/ (client-side address checker; 1,195 addresses / 1,082.65 BTC dataset)
|
||||
5. Bitcoin Magazine, *"Coinkite Releases Fixed Firmware After Coldcard Bug; AI Likely Involved In The Breach"* — https://bitcoinmagazine.com/business/coinkite-releases-fixed-firmware-after-coldcard-bug-ai-likely-involved-in-the-hack (fixed-firmware timing, NVK attribution, ~$70M/24h)
|
||||
- Corroborating secondary (not relied on for technical claims): Bitcoin Magazine https://bitcoinmagazine.com/news/coldcard-wallet-exposed-after-bitcoin-hack ; Protos https://protos.com/coldcard-attack-25-minutes-500-wallets-38m-in-btc-gone/
|
||||
|
||||
**Primary — historical catalogue**
|
||||
6. CVE-2023-39910 (Milk Sad, Libbitcoin Explorer 3.0.0–3.6.0) — https://nvd.nist.gov/vuln/detail/CVE-2023-39910 ; https://osv.dev/vulnerability/CVE-2023-39910 ; GHSA-prgj-h7jq-7p9h ; disclosure: https://milksad.info/
|
||||
7. CVE-2023-31290 (Trust Wallet Core <3.1.1 / extension <0.0.183) — https://nvd.nist.gov/vuln/detail/CVE-2023-31290 ; GHSA-pm4f-pggw-8jwc ; https://milksad.info/disclosure.html ; Ledger analysis: https://www.ledger.com/blog/funds-of-every-wallet-created-with-the-trust-wallet-browser-extension-could-have-been-stolen
|
||||
8. Unciphered, *"Randstorm: You Can't Patch a House of Cards"* — https://www.unciphered.com/disclosure-of-vulnerable-bitcoin-wallet-library-2/
|
||||
9. Amber Group, *"Exploiting the Profanity Flaw"* — https://medium.com/amber-group/exploiting-the-profanity-flaw-e986576de7ab ; CertiK Wintermute analysis: https://www.certik.com/resources/blog/uGiY0j3hwOzQOMcDPGoz9-wintermute-hack-
|
||||
|
||||
**Primary — Rust / browser entropy**
|
||||
10. rand CHANGELOG — https://github.com/rust-random/rand/blob/master/CHANGELOG.md (0.9.0 2025-01-27 fork-protection removal; 0.9.1 2025-04-17 "rand is not a crypto library"; 0.10.0 2026-02-08 `OsRng`→`SysRng`)
|
||||
11. RUSTSEC-2021-0023 (`rand_core` 0.6.0–0.6.1) — https://github.com/RustSec/advisory-db/blob/main/crates/rand_core/RUSTSEC-2021-0023.md ; database: https://rustsec.org/advisories/
|
||||
12. MDN, `Crypto.getRandomValues()` — https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues (**only** `Crypto` member usable from an insecure context; 65,536-byte limit; `SubtleCrypto` requires secure context)
|
||||
|
||||
**Primary — PSBT / descriptors / multisig / LND**
|
||||
13. Bitcoin Core, `doc/psbt.md` — https://github.com/bitcoin/bitcoin/blob/master/doc/psbt.md
|
||||
14. Bitcoin Core 30.0 release notes — https://bitcoincore.org/en/releases/30.0/ (BDB legacy wallet removal, `migratewallet`, PSBTv2/BIP-370 merge)
|
||||
15. Bitcoin Core, `doc/multisig-tutorial.md` — https://github.com/bitcoin/bitcoin/blob/master/doc/multisig-tutorial.md ; `test/functional/wallet_multisig_descriptor_psbt.py` — https://github.com/bitcoin/bitcoin/blob/master/test/functional/wallet_multisig_descriptor_psbt.py ; `doc/descriptors.md` — https://github.com/bitcoin/bitcoin/blob/master/doc/descriptors.md
|
||||
16. Coinkite, *"Descriptors & Multisig"* (Core 2-of-2) — https://coldcard.com/docs/bitcoin-core-2of2desc/
|
||||
17. BBQr specification — https://bbqr.org/ ; Coinkite, *"Bitcoin Air-Gap Signing Methods"* — https://coldcard.com/learn/advanced-concepts/air-gap-signing-methods
|
||||
18. Blockchain Commons, *"Animated QRs"* (UR / fountain codes) — https://developer.blockchaincommons.com/animated-qrs/
|
||||
19. LND, `docs/remote-signing.md` — https://github.com/lightningnetwork/lnd/blob/master/docs/remote-signing.md
|
||||
20. LND, `docs/psbt.md` — https://github.com/lightningnetwork/lnd/blob/master/docs/psbt.md ; Builder's Guide PSBT — https://docs.lightning.engineering/lightning-network-tools/lnd/psbt ; bulk PSBT — https://docs.lightning.engineering/lightning-network-tools/lnd/bulk-psbt ; PR #3722 (external funding / `PsbtShim`) — https://github.com/lightningnetwork/lnd/pull/3722
|
||||
|
||||
**Codebase inspection (this session, 2026-07-31)** — `core/archipelago/src/seed.rs`, `core/archipelago/src/api/rpc/seed_rpc.rs`, `core/archipelago/src/totp.rs`, `core/archipelago/Cargo.toml`, `~/.cargo/registry/src/**/bip39-2.1.0/src/lib.rs`, `neode-ui/src/views/Onboarding*.vue`, `apps/bitcoin-core/manifest.yml`, `apps/bitcoin-knots/manifest.yml`, `apps/lnd/manifest.yml`.
|
||||
|
||||
**Where live web contradicted prior knowledge:** the Coldcard entropy incident post-dates my training and was unknown to me before this session — every claim in §A.1 comes from the sources above, not from memory. The `rand` fork-protection removal in 0.9.0 and the `OsRng`→`SysRng` rename in 0.10.0 also corrected my priors.
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
---
|
||||
phase: quick-260731-upz
|
||||
plan: 01
|
||||
subsystem: security
|
||||
status: complete
|
||||
tags: [security, entropy, bip39, seed, psbt, audit, bitcoin, lnd]
|
||||
requires: []
|
||||
provides:
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md
|
||||
- docs/security/PSBT-SIGNING-ARCHITECTURE.md
|
||||
- injectable-RNG seam in core/archipelago/src/seed.rs
|
||||
affects:
|
||||
- core/archipelago/src/seed.rs
|
||||
- docs/UNIFIED-TASK-TRACKER.md
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Key-generation entropy source is passed as an argument, never inherited from a dependency default"
|
||||
- "Injection seam + deterministic test RNG as the regression guard for entropy-source rebinding"
|
||||
key-files:
|
||||
created:
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md
|
||||
- docs/security/PSBT-SIGNING-ARCHITECTURE.md
|
||||
modified:
|
||||
- core/archipelago/src/seed.rs
|
||||
- docs/UNIFIED-TASK-TRACKER.md
|
||||
decisions:
|
||||
- "image-recipe/_archived/ is NOT dead code — build-debian-iso.sh execs it; it is the live ISO builder and therefore in audit scope"
|
||||
- "ARCHY-1 fix applied as an injectable-RNG seam with a known-answer test; no derivation, word-count, passphrase or at-rest-encryption behaviour changed"
|
||||
- "ARCHY-5 refuted as a present defect (32 divides 256, so no modulo bias today) but retained as a latent one"
|
||||
- "PSBT spec ships wsh(sortedmulti) and defers taproot/MuSig2 as UNVERIFIED; BC-UR v2 chosen over BBQr on graceful-degradation grounds"
|
||||
- "Migration section deliberately does NOT tell Archipelago users to rotate seeds — the audit found no entropy defect, and over-alarming has real cost"
|
||||
metrics:
|
||||
duration: ~75min
|
||||
completed: 2026-08-01
|
||||
---
|
||||
|
||||
# Quick Task 260731-upz: Entropy/Seed Audit + PSBT Signing Architecture — Summary
|
||||
|
||||
Turned the confirmed 2026-07-30 Coinkite COLDCARD low-entropy incident into an
|
||||
evidence-backed audit of Archipelago's own entropy paths, a plannable PSBT-first signing
|
||||
spec, a prioritised remediation backlog wired into the tracker, and one small, test-proven
|
||||
hardening fix to master-seed generation.
|
||||
|
||||
## ⚠️ Push deliberately withheld
|
||||
|
||||
**Nothing was pushed.** Per explicit instruction, the `core/archipelago/src/seed.rs` diff is
|
||||
held for human review before it leaves this machine — it is master-seed generation code for
|
||||
every new node.
|
||||
|
||||
**Review commands:**
|
||||
|
||||
```bash
|
||||
git show 8b51b7e2 # the full seed.rs diff (122 insertions, 2 deletions)
|
||||
git log --oneline -4 # this plan's four commits
|
||||
git show --stat 8b51b7e2
|
||||
```
|
||||
|
||||
**Commits awaiting review, all on `main`, none pushed:**
|
||||
|
||||
| Commit | Type | Contents |
|
||||
|---|---|---|
|
||||
| `f11db4ea` | docs | `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` (the audit) |
|
||||
| `5faf1a3c` | docs | `docs/security/PSBT-SIGNING-ARCHITECTURE.md` (the spec) |
|
||||
| `5ba80e49` | docs | Remediation backlog + F-13 + tracker items |
|
||||
| `8b51b7e2` | **fix** | **`core/archipelago/src/seed.rs` — the diff to review** |
|
||||
|
||||
**What to check in `8b51b7e2`:** that the change is limited to (a) routing mnemonic
|
||||
generation through a helper that takes its RNG as a parameter, with `OsRng` passed at the
|
||||
production call site, and (b) two new tests — and that **no** derivation path, word count,
|
||||
BIP-39 passphrase decision, or at-rest encryption behaviour changed. It does not, but that
|
||||
is the thing worth confirming with your own eyes.
|
||||
|
||||
## Headline result
|
||||
|
||||
**No Coldcard-class entropy defect exists in this codebase.** Every first-party
|
||||
key-generation call site draws from a genuine CSPRNG. There is no Mersenne Twister, no
|
||||
clock-seeded key, no `SmallRng`, no `seed_from_u64`, and no `Math.random()` in any browser
|
||||
key path. The code also does several things better than most implementations (audit §5).
|
||||
|
||||
**But the audit found something more urgent than anything entropy-related.**
|
||||
|
||||
## The Critical finding (F-01) — not what we went looking for
|
||||
|
||||
`seed.generate` and `seed.restore` are in `UNAUTHENTICATED_METHODS`
|
||||
(`core/archipelago/src/api/rpc/middleware.rs:24-28`), which skips session, RBAC **and** CSRF.
|
||||
Neither handler checks whether onboarding is already complete, and
|
||||
`NodeIdentity::from_seed` (`core/archipelago/src/identity.rs:79-114`) overwrites `node_key`,
|
||||
`nostr_secret` and the FIPS mesh key **unconditionally**. There is no rate limit. The
|
||||
endpoint is proxied to the LAN over plaintext HTTP
|
||||
(`image-recipe/configs/nginx-archipelago.conf:11`, `:165`, `:192`) and mesh peers can reach
|
||||
it too (`core/archipelago/src/server.rs:2080`).
|
||||
|
||||
**One unauthenticated POST can take over or destroy a live node's identity**, and
|
||||
`seed.restore` lets the attacker choose the mnemonic. The guard already exists and is simply
|
||||
never called — `NodeIdentity::key_exists` (`identity.rs:117`).
|
||||
|
||||
Surfaced by tracing secret classes (3) and (4) end-to-end rather than only checking where
|
||||
their bits come from. Queued as backlog **R-01** and as a Tier 2 tracker item; it changes an
|
||||
authentication boundary on a live fleet and needs its own phase.
|
||||
|
||||
## ARCHY findings — adjudicated
|
||||
|
||||
| Tag | Verdict | Note |
|
||||
|---|---|---|
|
||||
| **[ARCHY-1]** | **CONFIRMED** | `seed.rs:92` → `bip39-2.1.0/src/lib.rs:311-313` → `:296-298` (`&mut rand::thread_rng()`) → `:267-283`. **FIXED.** |
|
||||
| **[ARCHY-2]** | **CONFIRMED (positive)** | The `GRND_NONBLOCK` probe is used as a probe only; its byte is discarded; no key material comes from it. Better than most. |
|
||||
| **[ARCHY-3]** | **PARTIALLY CONFIRMED** | The feared version does not exist. Three of four sub-questions answered from the tree; the rest is an UNVERIFIED on-node checklist. |
|
||||
| **[ARCHY-4]** | **CONFIRMED, and worse** | Every claim checks out, plus it is an integrity/availability exposure too — that is F-01. |
|
||||
| **[ARCHY-5]** | **REFUTED as a present defect** | `totp.rs:305`'s charset is 32 chars and 32 divides 256, so bias is **zero** today. Latent, not live. Stated plainly rather than dropped. |
|
||||
| Open Q9 | **DIVERGENCE CONFIRMED** | `Argon2::default()` = 19 MiB / t=2 / p=1; ADR-005 says 64 MB / 3. |
|
||||
|
||||
## Two findings the research did not predict
|
||||
|
||||
- **F-03 (High)** — the installed rootfs is a **cached container export shared by every
|
||||
node**, baking SSH host keys and a TLS keypair. Per-device regeneration exists and is
|
||||
correct in intent, but both branches are **fail-open** and `touch "$MARKER"` runs
|
||||
**unconditionally** (`image-recipe/_archived/build-auto-installer-iso.sh:1647`, `:1659`,
|
||||
`:1663`), so one transient failure permanently leaves that node on the image-wide shared
|
||||
keys, visible only in a log file.
|
||||
- **F-13 (High)** — `bitcoin.rs:203` passes `disable_private_keys=false` and `:229-231`
|
||||
imports `wpkh(xprv/...)`, so the BIP-84 account **private** key is persisted in Bitcoin
|
||||
Core's `wallet.dat` (with an empty wallet passphrase) in addition to the Argon2 envelope.
|
||||
The descriptors also carry no key-origin annotation, so no hardware signer could use them.
|
||||
|
||||
## Scoping correction worth carrying forward
|
||||
|
||||
`image-recipe/_archived/` is **not dead code**. `image-recipe/build-debian-iso.sh:19-40`
|
||||
copies `_archived/build-auto-installer-iso.sh` to a temp path, rewrites its relative paths,
|
||||
and `exec`s it. **The "archived" auto-installer is the live ISO builder.** The plan scoped it
|
||||
out; treating it as dead would have made [ARCHY-3] unanswerable and hidden F-03 entirely.
|
||||
|
||||
## Deliverables
|
||||
|
||||
**`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`** — 13 findings, each with severity,
|
||||
`file:line` evidence, exploitability, blast radius and concrete remediation; all five ARCHY
|
||||
tags adjudicated; all six mandated secret classes traced; a 13-item "What we do right"
|
||||
section; a 7-item UNVERIFIED on-node checklist with paste-ready commands; and an R-00…R-15
|
||||
remediation backlog. **103 `file:line` evidence references** (gate required ≥20).
|
||||
|
||||
**`docs/security/PSBT-SIGNING-ARCHITECTURE.md`** — watch-only descriptor wallets, the full
|
||||
Core RPC loop with wallet- vs node-scoped RPCs, `analyzepsbt`-driven UI state, Tier 1
|
||||
single-sig and Tier 2 `wsh(sortedmulti)` on BIP-48, BC-UR v2 vs BBQr vs file transport, the
|
||||
honest LND capability matrix, the hot wallet as an explicitly-secondary tier, migration
|
||||
guidance, and a 7-phase rollout with dependencies and candidate requirements. Cross-links
|
||||
and answers two open items in `docs/hardware-signer-design.md`.
|
||||
|
||||
**`docs/UNIFIED-TASK-TRACKER.md`** — 9 new items in the file's existing tier/checkbox format:
|
||||
4 in Tier 0, 3 in Tier 1, 4 in Tier 2 (including the Critical F-01 item and PSBT Phase 1).
|
||||
|
||||
## The one code change
|
||||
|
||||
`core/archipelago/src/seed.rs` — `generate_mnemonic_with<R: CryptoRng + RngCore>` calls
|
||||
bip39's **injectable** `generate_in_with`; `MasterSeed::generate()` passes `OsRng` explicitly.
|
||||
|
||||
`mnemonic_generation_uses_injected_rng` asserts the result equals
|
||||
`bip39::Mnemonic::from_entropy(<the exact bytes the test RNG emitted>)` — the direct proof
|
||||
that the **injected** RNG, not bip39's transitive default, is the one consumed — plus a
|
||||
known-answer pin and a determinism check. **This test cannot be written against the previous
|
||||
code**, because `Mnemonic::generate(24)` exposes no seam.
|
||||
|
||||
**Verified:** `CARGO_INCREMENTAL=0 cargo test -p archipelago seed::` → **25 passed, 0
|
||||
failed** (23 pre-existing + 2 new).
|
||||
|
||||
**Honest limitation, recorded in the audit:** this removes a *future* failure mode. It does
|
||||
not retroactively change seeds generated before it, which came from `rand::thread_rng()` —
|
||||
a genuine CSPRNG, so nothing is weakened, but their guarantee rests on `rand 0.8.5`'s
|
||||
behaviour rather than on this call site.
|
||||
|
||||
## Deviations from plan
|
||||
|
||||
1. **`image-recipe/_archived/` brought into scope** (plan said excluded). Justified above;
|
||||
documented in the audit's §1 so the next auditor does not re-derive it.
|
||||
2. **F-13 added to the audit during Task 3.** Discovered while reading `bitcoin.rs` for the
|
||||
PSBT spec. It belongs to secret class (1), which Task 1 was required to trace, so it was
|
||||
written up rather than left in the spec alone.
|
||||
3. **R-12 (`totp.rs` modulo bias) NOT applied**, though the plan permitted it. `[ARCHY-5]`
|
||||
was refuted as a present defect — 32 divides 256, so there is no bias today. Changing
|
||||
working crypto code for a latent-only issue did not meet the plan's "small and obviously
|
||||
correct" bar during a security-sensitive pass. Queued as R-12.
|
||||
4. **`cargo audit` not run** — `cargo-audit` is not installed. Recorded as gap F-07 with
|
||||
CI remediation R-05 rather than silently skipped.
|
||||
|
||||
## Not done, deliberately
|
||||
|
||||
- **No push, no tag, no deploy** (see the banner above).
|
||||
- **No PSBT/watch-only/multisig implementation** — the spec is a spec.
|
||||
- **`core/archipelago/src/container/secrets.rs` untouched** (backlog R-13) — it carried
|
||||
another agent's uncommitted work. Read-only for the audit, as required.
|
||||
|
||||
## Concurrent-agent hygiene
|
||||
|
||||
All four commits verified against the forbidden-path list: **no commit authored by this plan
|
||||
contains any of the other agents' files.** Every commit staged by explicit path; no
|
||||
`git add -A`, no `git add .`, no `git commit -a`. The submodule guard (`indeedhub`) ran
|
||||
before each commit and passed. Their uncommitted work (`ScreensaverRing.vue`,
|
||||
`SendBitcoinModal.vue`, `WalletScanModal.vue`, and the earlier set) is intact.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — FOUND
|
||||
- `docs/security/PSBT-SIGNING-ARCHITECTURE.md` — FOUND
|
||||
- `core/archipelago/src/seed.rs` — modified, tests green
|
||||
- Commits `f11db4ea`, `5faf1a3c`, `5ba80e49`, `8b51b7e2` — all FOUND in `git log`
|
||||
- Task 1 verify gate — OK (103 evidence refs, all required tokens present, no secret-shaped
|
||||
strings)
|
||||
- Task 2 verify gate — OK (all 12 required tokens present, no secret-shaped strings)
|
||||
- Task 3 verify gate — OK (backlog present, both tracker links present, no forbidden paths in
|
||||
any of the four commits)
|
||||
- No real secret value appears in any produced document — verified by pattern scan on both.
|
||||
Reference in New Issue
Block a user