Addresses the P0 items in the security review at
docs/plans/2026-07-01-privacy-lock-security-review.md.
## PBKDF2 iterations 100k → 600k (M1) via versioned hash format (M2)
- New PasswordHasher storage format: `v1$saltB64$hashB64` (600k
iterations, matches OWASP 2023 Password Storage Cheat Sheet for
PBKDF2-HMAC-SHA256).
- Legacy `saltB64$hashB64` (100k iterations) format still verifies
correctly — no user gets locked out by the bump.
- `hash()` always produces `v1$…`; users migrate to v1 opportunistically
when they Change or Set a new password.
- New `PasswordHasher.isLegacyFormat()` helper for callers that want
to force-migrate on next successful unlock.
- Verify cost goes from ~50ms → ~250ms on a modern laptop — well
within tolerable UX for a lock users open a handful of times per
session.
## Exponential backoff on failed unlock (M3)
- `PrivacyLockSettings` gains `failedUnlockAttempts: StateFlow<Int>`
and `lockedUntilEpochMs: StateFlow<Long?>`, both persisted via
java.util.prefs so a reboot cannot reset the backoff.
- `MessagesLockState.onFailedUnlockAttempt(nowMs)` implements the
schedule: no lockout for first 4 fails, then 30s / 60s / 120s /
300s (capped at 5 min).
- `MessagesLockState.onUnlockSuccess()` transparently clears the
attempt counter and any active lockout (also called from the
banner-enable path).
- DesktopLockScreen shows a countdown ("Try again in 27s") in the
supportingText, disables the password field and Unlock button
during lockout, ticks every 500ms via a LaunchedEffect.
- RemovePasswordDialog inherits the same protection — Settings can't
bypass the throttle by disabling the lock.
- 4 new unit tests cover threshold behavior, base trip, doubling +
cap, reset on success. All 13 tests green.
## Not in this commit
- L1/L2 (String/CharArray memory retention) — out-of-tree fix in
Compose; accepted per threat model.
- L3 (post-uninstall prefs) — release-notes item.
- M4 (Limitations copy update) — deferred; existing "does not
protect against filesystem access" line already covers.
Damus-inspired content filter that collapses notes abusing `t` hashtag
tags into a compact reveal-on-click placeholder. Ships default ON with a
threshold of 5 (adjustable 1–20 in Settings → Content Filters, or off).
Scope
- Pure check (`HashtagSpamCheck`) + settings interface
(`HashtagSpamSettings`) live in `commons/moderation/`, callable by
Desktop, `amy` CLI, and (future) Android.
- JVM-backed `PreferencesHashtagSpamSettings` writes to the shared
`java.util.prefs` node `com/vitorpamplona/amethyst/filters`, so `amy`
and Desktop observe the same value automatically.
- `CollapsedSpamNote` placeholder in `commons/ui/note/` takes only
primitive scalars so Android can adopt it without touching commons.
- Desktop wraps every `NoteCard` call site (FeedNoteCard, QuotedNoteEmbed,
BookmarksScreen, 5 SearchResultsList sites) with a shared
`SpamCheckedNoteRender` helper. Thread root notes auto-expand via
`forceReveal=true`; replies still respect the filter.
Exemptions
- Long-form articles (kind 30023)
- Authors in the follow list plus self
- Repost wrappers check the inner event's tags via precomputed
`note.replyTo`, falling back to `containedPost()`
Search UX fixes bundled in
- Removed the `#hashtag` → "Direct lookup" card. `QueryParser` already
extracts `#xxx` into the query's hashtag filter, so typing `#bitcoin`
now goes straight to filtered results.
- Search-result rows now trigger metadata loading via
`subscriptionsCoordinator.loadMetadataBatched(authors)` and observe
each user's metadata flow via a new `rememberDisplayData` helper, so
display names + avatars refresh when kind-0 arrives from index
relays. Same helper reused in Bookmarks.
Tests + docs
- 19 unit tests (check × 10, displayed-event unwrap × 4, prefs × 5),
all green.
- Manual testing sheet with 16 scenarios at
`desktopApp/plans/2026-06-29-hashtag-spam-filter-manual-testing-sheet.md`.
- Plan at `docs/plans/2026-06-29-feat-desktop-hashtag-spam-filter-plan.md`.
- Cross-client desktop feature backlog reference at
`desktopApp/plans/_desktop-feature-backlog.md`.
Audited all 143 plan files across the 10 plans/ folders. Each plan now
carries a Status header (shipped | in-progress | queued | abandoned)
backed by codebase evidence, and every folder has a README.md index
grouping plans by status.
Shipped plans were moved into a per-folder plans/archive/ (via git mv,
history preserved) so each plans/ folder surfaces only live work:
shipped (archived): 122 in-progress: 8 queued: 7 abandoned: 4
docs/plans/ is the frozen legacy folder; its plans were stamped and
indexed in place (48 of 52 archived) but it remains closed to new plans.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016hpUivtmq4pgzqRbY6MYrA
The original "ProGuard strips the keychain backend" hypothesis turned out
to be wrong twice (PR 3260 comments document the binary PoW that refuted
both H1 strip-of-classes and H1b strip-of-native-resource). The full
117 KB osxkeychain.so resource ships intact in the proguarded
jkeychain-1.1.0-*.jar today, and Keyring.create() round-trips fine
against the proguarded classpath on macOS.
But the user-reported bug pattern (every cold boot, keychain key missing
→ forced re-login) maps so cleanly onto a hypothetical future
strip-of-native-resource that the guard is worth keeping. Cheap to run
(one unzip scan after proguardReleaseJars), wired onto every release
packaging task (DMG, MSI, DEB, RPM, current-OS distributable, runRelease)
so a regression can't slip past. Fails the build with a self-contained
explanation pointing at the next person who has to debug it.
The actual root cause of the reported bug remains unidentified after
three refuted hypotheses (see plan doc PoW table); needs the affected
user's Console.app logs + ~/.amethyst state to make further progress.
The LoginScreen "keychain-unavailable" diagnostic banner from the
earlier commit is unchanged and still earns its keep regardless of
which failure mode eventually turns out to be the cause.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Built ./gradlew :desktopApp:proguardReleaseJars on both main and this
branch and inspected the shrunk java-keyring-1.0.4-*.jar in
desktopApp/build/compose/tmp/main-release/proguard/. Both branches
contain byte-identical macOS Keychain backend bytecode:
OsxKeychainBackend, ModernOsxKeychainBackend,
pt/davidafsilva/apple/OSXKeychain, plus all _addGenericPassword /
_findGenericPassword / _deleteGenericPassword / loadSharedObject native
methods. ProGuard is NOT stripping the macOS backend.
The compose-rules.pro comment had misled me. pt.davidafsilva.apple IS a
real transitive runtime dep of com.github.javakeyring:java-keyring —
ModernOsxKeychainBackend has a private pt.davidafsilva.apple.OSXKeychain
field. The original keep rule was correct; restore it and clarify the
comment about the transitive relationship so the next person to read
this code doesn't repeat the same mistake.
The AccountManager keychain-unavailable diagnostic + LoginScreen banner
introduced earlier in this branch are kept — they're useful for any
future failure mode in this area, not just the (refuted) ProGuard one.
See https://github.com/vitorpamplona/amethyst/pull/3260#issuecomment-4740073787
for the full PoW jar inspection. Remaining hypotheses (H2 hardened-runtime
unsigned-dylib block, H4 jpackage stripping the bundled libosxkeychain.dylib,
H5 v1.11.0 migration gap) are documented in the plan doc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ProGuard in the release DMG (compose-rules.pro) was keeping
pt.davidafsilva.apple.** — a library no longer in the dependency graph.
The actual macOS-keychain dependency is com.github.javakeyring:java-keyring,
which reflection-loads its OS-specific backend (OSXKeychainBackend /
SecretServiceBackend / WinCredentialStoreBackend) at Keyring.create()
time. The shrinker stripped the backend classes, Keyring.create() threw
BackendNotSupportedException on every cold boot, SecureKeyStorage's
fallback silently returned null (no password prompt in a GUI cold-boot),
and every account whose key lived in the OS keychain (nsec, NIP-46
bunker ephemeral, NWC secret) was forced back to the login screen on
each launch of the release DMG. Dev/Gradle runs skip ProGuard, which is
why this never surfaced in development.
Primary fix:
- Replace dead pt.davidafsilva.apple.** keep rules with
com.github.javakeyring.** and keep native methods + constructors on
internal.** backends.
Defense in depth (so a future regression is visible, not silent):
- AccountManager._keychainUnavailable: StateFlow<Boolean> mirrors the
existing _storageCorruption / _forceLogoutReason channels.
- loadInternalAccount / loadBunkerAccount raise the signal when
accounts.json.enc points at a key the keychain cannot return.
- LoginScreen shows a one-line error banner when the signal is set;
cleared on any successful login.
Tests:
- AccountManagerLoadAccountTest gains four cases: Internal-no-privkey
signals, Bunker-no-ephemeral signals, clearKeychainUnavailable
resets, happy path does NOT signal.
See docs/plans/2026-06-18-fix-desktop-macos-bunker-relogin-plan.md for
brainstorm + plan + deferred follow-ups (Linux/Windows DMG verification,
signed-DMG smoke test, ProGuard mapping regression guard).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaces relays unresponsive for 7+ days across the user's NIP-65 (10002),
DM (10050), and Search (10007) relay lists. A non-modal banner appears
above feed columns (and above the single-pane content) whenever the
classifier finds anything; tapping it opens an anchored Popup with one
row per unhealthy relay and per-row Remove / Open Dashboard / Snooze 7d
actions plus a banner-level "Snooze all 7d".
Quartz
- RelayStat gains best-effort lastConnectAt + lastIncomingAt timestamps
(epoch seconds, 0 = never observed). RelayStats listener pushes them
on onConnected / onIncomingMessage. Durable per-relay history lives
outside quartz in the commons RelayHealthStore.
Commons (new commons/relays/health/ package)
- classifyRelayHealth() pure function with the v1 gates:
* first-run grace (don't flag for 7d after firstScanAt)
* offline grace (don't flag if no relay anywhere has responded)
* Tor-mode skip (relay timing is intentionally lossy through Tor)
* per-relay snooze (snoozedUntil > now)
* 10006 (blocked) excluded from detection but still part of the
multi-list Remove action
- RelayHealthStore (account-scoped, supervised scope, 5s debounced
persist, 60s ticker for snooze expiry).
- RelayHealthListener wires the quartz lifecycle into the store.
- RelayHealthPersistence interface (no expect/actual — single impl per
platform via injection).
- RelayListMutator interface + RelayRemovalResult sealed type.
- Shared UnhealthyRelayBanner (errorContainer @ 50% alpha) and
UnhealthyRelayRow (static outlined tag chips, no ripple) composables.
- 8 classifier unit tests covering each gate + multi-list membership.
Desktop wiring
- PreferencesRelayHealthPersistence (java.util.prefs.Preferences, per
account via 8-char pubkey prefix).
- DesktopRelayListMutator runs the 4 sign-and-broadcast jobs in
parallel via async/awaitAll so a slow NIP-46 bunker doesn't multiply
latency by 4.
- Banner placed in DeckColumnContainer + SinglePaneLayout, store +
listener + per-account scan trigger wired in Main.kt's MainContent.
Scope: Desktop only for v1. Android wiring is intentionally not in
this PR — the commons module is platform-neutral and ready for Android
to follow whenever someone wants to pick it up.
Adds a dedicated "Replies" tab between Notes and Reads on the desktop
profile screen so the reply-context rendering can be eyeballed on a
specific user's profile without scroll-hunting for an organic reply.
- DesktopProfileFeedFilter gains a repliesOnly: Boolean = false ctor
param. Default keeps Notes-tab behavior unchanged; when true, the
predicate becomes `event is TextNoteEvent && !note.isNewThread()`
(excludes reposts and chat-message kinds in one check).
- UserProfileScreen: second DesktopFeedViewModel for the replies feed,
new tab at index 1, body branch mirroring the Notes Loading/Empty/
Error/Loaded states. Reads/Gallery/Highlights indices shift by 1.
NIP-22 kind 1111 deferred — most replies today are kind 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Detect NIP-10 / NIP-22 replies in the desktop feed pipeline and render an
embedded parent card plus a "Replying to @displayName" label above the
reply body, matching Android's home-feed behavior. Extracts the shared
ReplyToLabel composable + ReplyContext data class to commons so Android
switches over to the shared version.
- commons/.../ui/note/ReplyContext.kt: data class + from(event, cache)
detection. NIP-10 + NIP-22 unified via BaseThreadedEvent polymorphism.
- commons/.../ui/note/ReplyToLabel.kt: shared composable.
- commons/strings.xml: new "Notes & Replies" section + replying_to key.
- desktopApp NoteCard: replyContext param + render branch (bordered
QuotedNoteEmbed + ReplyToLabel). Recursion impossible because
QuotedNoteEmbed's inner NoteCard call doesn't pass replyContext.
- desktopApp FeedScreen: rememberReplyContext() observes parent
metadata flow so embed/label pop in once the parent arrives via
relay subscription. Wired into both regular and reposted-inner paths.
- amethyst ReplyInformation.kt: removed local ReplyToLabel definition.
- amethyst Text.kt: calls shared commons ReplyToLabel; resolves author
display name at the call site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the perceptual "stale feed on launch" bug: on cold launch the
desktop feed paints with whatever local cache had (up to 7 days old)
before relays catch up. The live updateFeedWith() path already prepends
fresh events silently, but users had no signal that fresh content
arrived unless they were already at the top of the feed (auto-snap via
StickToTopOnPrepend).
This adds a Twitter/Mastodon-style floating pill chip that slides down
from above the search header when fresh events have prepended AND the
user is scrolled below position 0. Tapping it smooth-scrolls to top
and slides the chip back up off-screen. Scrolling to top manually
also dismisses it.
Implementation:
- NewPostsChip + rememberNewPostsChipState in commons/commonMain so any
future feed surface (incl. Android, iOS) can adopt it. Desktop wires
it today; Android continues with the existing auto-stick + bottom-nav
dot pattern.
- Visibility predicate is pure-function and unit-tested (5 cases).
- Predicate mirrors the inverse of StickToTopOnPrepend's "at top" check
so the two systems are mutually exclusive — auto-snap when at top,
chip when not.
- Chip placement: floating Alignment.TopCenter inside FeedScreen's outer
Box, offset by the animated headerSpacerHeight (60.dp normal,
300.dp when search is expanded) so it tracks the header card.
- Hoisted lazyListState + headerSpacerHeight one level so the chip can
share scroll state with the LazyColumn. Existing viewport-aware
metadata loading is unchanged (same lazyListState reference).
- Animation: slideInVertically(tween(280, FastOutSlowInEasing)) + fadeIn
for enter; slideOutVertically(tween(220, FastOutLinearInEasing)) +
fadeOut for exit. Initial/target offset of -fullHeight-16 guarantees
the chip is fully off-screen above its rest position.
- Per-column scope by construction: each FeedScreen instance has its
own chip state (deck mode shows one chip per column).
- Resets cleanly on feed mode switch (Following ↔ Global ↔ Custom)
because rememberNewPostsChipState is keyed on FeedContentState,
which is recreated when viewModel = remember(feedMode, activeFeedId)
recomposes.
Plan: docs/plans/2026-06-02-feat-new-posts-chip-desktop-feed-plan.md
5 issues from davotoula's review on PR #3124:
- #3 (protocol): inline reply emitted a minimal e/p tag set instead of
NIP-10. Extract `commons/actions/ReplyActions.replyTo` wrapping
`TextNoteEvent.build(replyingTo=)` (which already encodes root marker,
reply marker, parent root-e-tag carry) + carry parent's p-tag chain via
`notify(...)`. Replies to deep-thread notes now thread correctly in
Damus/Primal/Coracle. Covered by `ReplyActionsTest`.
- #4 (architecture): reaction/follow/reply each inlined
`localCache.consume + relayManager.broadcastToAll` in 5 sites with
inconsistent ordering. Extract `desktopApp/cache/dispatch(...)` —
canonical local-first order — and route all 5 sites through it.
- #1 (UX): related-content section scanned the cache once via
`DisposableEffect(noteId)` and never refreshed. Switch to `produceState`
collecting `DesktopLocalCache.eventStream.newEventBundles`; re-scan only
when an arriving bundle contains a candidate (matching hashtag or
author). `LargeCache.notes` is a ConcurrentSkipListMap (weakly consistent
iterator) so the scan stays safe on the composition coroutine.
- #2 (UX): `DeckColumnContainer` re-requested focus on every
`currentOverlay` change, stealing focus from sibling columns whenever
any column mutated overlay state. Drop to `LaunchedEffect(Unit)` and
wrap the column in `key(column.id)` in `DeckLayout` so the one-shot
effect survives column reordering.
- #5 (consistency): zap totals bypassed the shared `ZapFormatter`. Wire
`RelatedContentRow`, `CommentItem`, and `NoteActions` to
`commons/util/ZapFormatter.{showAmount,toZapAmount}`; delete
`formatZapAmount` and `formatSats` desktop-local helpers.
`WalletColumnScreen.formatSats` intentionally kept — locale-aware full
precision for wallet balance is by design.
Plan: docs/plans/2026-06-02-fix-desktop-feed-review-findings-plan.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the single-field display name AlertDialog with a comprehensive
profile editing Dialog supporting all 13 Nostr profile fields: name,
display name, about, avatar, banner, website, pronouns, NIP-05,
lightning address, LNURL, and NIP-39 social proofs (Twitter, GitHub,
Mastodon).
New shared EditProfileFields state holder in commons/commonMain using
MutableStateFlow (matching ChatNewMessageState pattern) benefits both
Android and Desktop platforms.
Desktop-native features:
- Blossom image upload via DesktopFilePicker + UploadOrchestrator
- Live NIP-05 verification with debounced network check
- Keyboard shortcuts: Ctrl+S/Cmd+S save, Esc cancel
- Unsaved changes confirmation dialog
- Collapsible social proofs section
- Avatar/banner URL live preview via AsyncImage
- ProfileBroadcastBanner for relay broadcast feedback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Wire AccountManager.setNwcConnection() and clearNwcConnection() into
wallet column for persistent connect/disconnect
- Implement NwcPaymentHandler.getBalance() via NIP-47 get_balance RPC
- Implement NwcPaymentHandler.makeInvoice() via NIP-47 make_invoice RPC
- Add generic waitForGenericResponse() helper for NWC RPC operations
- Auto-fetch balance on wallet column load via LaunchedEffect
- Wire receive screen to generate real invoices via NWC
- All wallet column features now functional (no blocking TODOs)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CRITICAL: Move NWC wallet secret from plaintext nwc_connection.txt to OS
keychain. The NWC secret is a private key that can authorize Lightning
payments — storing it in plaintext allowed any process to steal funds.
Security fixes:
- NWC secret stored in OS keychain as "nwc_<npub>" (per-account)
- accounts.json.enc is now the sole source of truth for cold boot
- Eliminate bunker_uri.txt, last_account.txt, nwc_connection.txt
- Legacy files deleted on first startup (one-time cleanup)
- logout(deleteKey=true) now removes account from accounts.json.enc
- Corrupted accounts.json.enc backed up as .corrupt.<timestamp>
Cold boot rewrite:
- loadSavedAccount() routes by SignerType from accounts.json.enc
- No longer reads stale bunker_uri.txt (fixes nsec→bunker confusion)
- No longer reads last_account.txt (uses activeNpub from metadata)
Multi-account improvements:
- NWC connections are per-account (switch account = switch wallet)
- Each account type (Internal/Remote/ViewOnly) loads correctly
- saveBunkerAccount() no longer writes to bunker_uri.txt
Updated 8 existing test files to use accountStorage instead of
writing legacy files directly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Merge upstream main into feat/desktop-multi-account, resolving:
- Main.kt: Surface wrapper + CompositionLocalProvider + nip11Fetcher from upstream, multi-account DeckSidebar/LoginScreen from HEAD
- FeedScreen.kt: viewport-aware scroll from HEAD + contentPadding from upstream
- DeckSidebar.kt: merged imports (multi-account + titleBarInsetTop + MaterialSymbols)
- Migrated AccountSwitcherDropdown + AddAccountDialog from material.icons to MaterialSymbols
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Account switcher dropdown improvements:
- Two-row display: Display Name on top, npub (middle-truncated) below
e.g. 'Alice' / 'npub1abc...wxyz · Bunker'
- Middle-truncation for npub: shows first 10 + last 6 chars
- Resolves display names from DesktopLocalCache user metadata
- Confirmation dialog also shows display name
- npub-only (view-only) accounts now persist to encrypted storage
(ensureCurrentAccountInStorage called in onLoginSuccess)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Existing plan docs were written before the moq-lite swap, the
create-space + kind-10112 work, and the harness / submodule findings.
This refresh aligns them with what's actually live on the branch and
captures the work still ahead.
- 2026-04-26-audio-rooms-completion.md — flipped to a STATUS-FIRST
layout: implementation table for every protocol/transport/UI
surface, "pending" table for the remaining items (reconnect,
level meters, Desktop / iOS, Nests parity), pointers section
refreshed.
- 2026-04-26-moq-lite-gap.md — marked DONE with the commit range
that landed it (fb47a4c → 71cf99d → 015b0d7); "When picking up"
section now points at the shipped surface first, raw protocol
references second.
- 2026-04-22-nip-audio-rooms-draft.md — major surgery to match
today's nostrnests reality:
* status banner up top calling out the revision
* dependencies dropped IETF MoQ-transport, added moq-lite
Lite-03 + ALPN "moq-lite-03"
* HTTP control plane: GET <service>/<room-d-tag> → POST /auth
with {namespace, publish}, returning {token}; documented the
JWT claim shape (root, get, put), 600 s lifetime, regex
on `namespace`, JWKS endpoint, error matrix
* Audio transport: replaced IETF SETUP / TrackNamespace tuples
/ OBJECT_DATAGRAM with moq-lite Lite-03 (ControlType varint,
per-bidi message types, group uni streams, audio/data track,
no in-band SETUP, FIN-as-unsubscribe semantics)
* New event-kind sections: kind 4312 (admin command / kick),
kind 10112 (audio-room server list)
* Reconciliation section explaining what changed from the
original draft and why
- NEW: 2026-04-26-nostrnests-integration-audit.md — punchlist of
every nostrnests/NestsUI feature we don't yet ship, sourced from
a code-walk of the React app + moq-auth + API.md (which is
LiveKit-era and dead). Tier 1 (low-effort, visible): chat,
reactions, role parsing + promotion, hand-raise queue, kick
(kind 4312), edit/close room, scheduled rooms, listener counter.
Tier 2: participant grid, augmented presence tags
(publishing/onstage), per-avatar context menu + zap, share via
naddr. Tier 3: room theming. Tier 4: token-refresh +
Connection.Reload sanity checks.
Verified `:nestsClient:jvmTest` + `:amethyst:compilePlayDebugKotlin`
both still green after the doc changes (no code touched).
- Persist relay list events (kinds 10050/10007/10006) as JSON to
java.util.prefs.Preferences with per-account key isolation
- Load persisted relay configs on startup before bootstrap subscription
- Validate loaded events (kind + pubkey check), 8KB guard on writes
- Fix SearchScreen relay count: "0 of 1" not "0 of 7" — uses searchRelays
- Fix FeedScreen relay count: shows feed relay count, not all connected
- Per-screen relay picker dialogs: Dns icon on Feed and Search screens
opens AlertDialog wrapping existing editors (Nip65RelayEditor,
SearchRelayEditor) — no new composable files
- Fix created_at dedup: use >= for replaceable event semantics
- Fix setters: use TimeUtils.now() not Long.MAX_VALUE
- Add consumePublishedEvent() for local immediate update after publish
- Remove stale "not loaded" warnings from Search/Blocked editors
- Fix FeedHeader type: Set<NormalizedRelayUrl> not Set<Any>
- Fix picker LaunchedEffect(Unit) to not overwrite user edits
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Captures the gap between NIP-53's room discovery and what audio-capable
clients/servers must actually agree on to interop. NIP-53 defines the
30312 event but leaves the HTTP control plane + MoQ namespacing +
audio codec params entirely to individual implementations. With Nests
going generic-server, this is the moment to standardize.
What the draft covers:
1. HTTP control plane:
- Path convention: GET <service>/<d-tag>
- NIP-98 `Authorization: Nostr <base64>` header required
- JSON response shape (endpoint, token, transport, codec,
sample_rate, frame_duration_ms, moq_version)
- Canonical error-status map (401/403/404/410/503)
2. WebTransport + MoQ handshake requirements (Extended CONNECT,
required HTTP/3 settings, Bearer token passing).
3. MoQ track naming — vendor-neutral one-element namespace
`[<d-tag>]` with track-name = speaker-pubkey-hex. Explicit
rejection of the `["nests", <d-tag>]` prefix for new deployments.
4. Audio object format: raw Opus packets (no Ogg, no TOC), 48 kHz
mono, 20 ms default, mono PCM 16-bit decode target. Both
OBJECT_DATAGRAM and STREAM_HEADER_SUBGROUP accepted; listeners
MUST handle both.
5. Per-track access control: server MUST verify publishing pubkey is
a host/speaker in the current 30312 event (which is replaceable —
revocation cascade is spec'd).
6. Leave procedure (UNSUBSCRIBE, UNANNOUNCE, final 10312 presence,
WT_CLOSE_SESSION capsule).
7. Presence extension: the `["muted", "1"|"0"]` tag we already ship
in nestsClient's MeetingRoomPresenceEvent overload, promoted from
Amethyst-specific to NIP-defined.
8. Server + client requirements summaries.
9. Known divergences from current nostrnests/nests servers (two-
element `["nests", <d-tag>]` namespace + `/api/v1/nests/<d-tag>`
path) with a transition strategy via a `"nip_xx": true` flag in
room-info responses.
10. Security considerations (bearer-token handling, NIP-98 `u` tag
binding, audio-replay attack surface, server-impersonation VU
meter recommendation).
Deliberately out of scope: E2E-signed audio objects (future NIP),
federation between audio servers, room recording/transcription.
Intended workflow: share with the Nests team while they're designing
the generic server, land changes against their feedback, then open a
PR on nostr-protocol/nips.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Updates the Architecture section to reflect the decision to house the
pure-Kotlin QUIC + HTTP/3 + WebTransport code in a new top-level
Gradle module `:quic`, sibling to `:quartz`, `:commons`, `:nestsClient`.
Key changes:
- Module placement: new KMP module `:quic` with commonMain +
jvmAndroid + jvmMain + androidMain source sets (mirrors Quartz).
`:quic` takes `api(project(":quartz"))` so all crypto primitives
are in-scope without re-export.
- settings.gradle delta spelled out.
- Package layout shifts from
`nestsClient/src/jvmAndroid/...transport/quic/` to
`:quic/src/commonMain/com/vitorpamplona/quic/`, with UDP
socket-specific bits in `jvmAndroid/`.
- Varint.kt migration called out: moves from
`nestsClient/moq/Varint.kt` to `:quic` at
`com.vitorpamplona.quic.Varint`. Mechanical, one commit.
- Rename KwikWebTransportFactory stub → QuicWebTransportFactory
(real), living in `:quic` but implementing `:nestsClient`'s
existing `WebTransportFactory` interface. AudioRoomConnectionViewModel
changes one ctor call.
- Rationale section documenting why Option A beats putting it in
`:nestsClient` (single responsibility / reusability / security
boundary / test isolation / build graph / charter fit).
- Phase A now explicitly includes the module-creation +
Varint-migration step; test suite must stay green across the move.
Timeline unchanged (17-19 weeks). Dependency story unchanged
(no external libs; everything piggybacks on Quartz).
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Audited Quartz's crypto surface and found every primitive the QUIC
plan called out for BouncyCastle is already present in commonMain:
- `utils/ciphers/AESGCM.kt` — AES-GCM with AAD (QUIC-TLS AEAD)
- `nip44Encryption/crypto/ChaCha20Poly1305.kt` — pure-Kotlin AEAD
- `nip44Encryption/crypto/Hkdf.kt` + `utils/mac/MacInstance.kt` — HKDF
- `utils/sha256/Sha256.kt` — SHA-256
- `marmot/mls/crypto/X25519.kt` — X25519 ECDH (TLS 1.3 key exchange)
- `marmot/mls/crypto/Ed25519.kt` — Ed25519 signatures
- `utils/SecureRandom.kt`
Plan update highlights:
- "What we delegate" section rewritten to point at Quartz primitives.
- Phase B renamed from "TLS via BouncyCastle" to "TLS 1.3 client state
machine on Quartz primitives"; bumped from 2 to 3 weeks because we
write the state machine ourselves, but eliminates the entire
BC-adapter integration risk.
- "Dependencies to add" section zeroed out — nothing goes in
gradle/libs.versions.toml. The only new primitive is a thin
HKDF-Expand-Label helper on top of existing `MacInstance`, which
we can upstream to Quartz's `Hkdf` as a general `expand(prk,
info, length)`.
- Risk table rewritten: removed BC-specific risks, added
Quartz-specific ones (verify `X25519.dh` against RFC 7748 vector
on Phase B day-1).
- Cert chain signature verification uses JDK `Signature` for RSA +
ECDSA plus Quartz `Ed25519` for Ed25519 leaves.
Total timeline moves from 16-18 weeks to 17-19 weeks — same band,
but with zero external dependencies and zero Maven-resolution risk.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Captures the plan to unblock Phase 3b-2 (the WebTransport handshake)
by writing a Kotlin QUIC client rather than depending on a Java QUIC
library. Triggered by exhausting the off-the-shelf options:
- tech.kwik:* coords don't exist on Maven Central we can resolve.
- Netty incubator HTTP/3 needs an Android quiche-native that isn't
published.
- Cronet doesn't expose WebTransport.
- WebView JS bridge rejected by product.
The plan delegates TLS 1.3 + crypto primitives to BouncyCastle (bcprov
+ bcpkix already cached, bctls to add) and has us writing the QUIC
packet/frame/state-machine layer + HTTP/3 + WebTransport on top.
Realistic estimate: 16-18 weeks one developer full-time, or 5-6
months at a normal cadence, plus a security review before shipping.
Hard abandonment trigger documented: if the RFC 9001 Appendix A
Initial-packet test vectors don't bit-match by end of Phase D
(~6 weeks in), abandon and wait for an Android-compatible upstream.
The plan is committed as a doc rather than executed in this session
because each phase is multi-week and would block the rest of the
audio-rooms feature in the meantime.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Remove the LaunchedEffect/scope.launch reconnect code that didn't
work (relay subscriptions lost after disconnect+connect cycle).
Tor toggle will use app rebuild via key() instead (next commit).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SECURITY: With Tor ON, all HTTP traffic now routes through SOCKS proxy.
Previously only relay WebSocket connections used Tor; 8 other egress
paths created bare OkHttpClient() instances bypassing Tor entirely.
Fail-closed behavior:
- When Tor expected but bootstrapping → dead SOCKS proxy on port 1
(requests fail instead of leaking IP)
- lateinit var crashes on misconfiguration (loud failure vs silent leak)
Leak sites fixed (all use DesktopHttpClient.currentClient()):
- AnimatedGifImage: GIF fetch
- SaveMediaAction: media downloads
- EncryptedMediaService: NIP-17 DM media
- ServerHealthCheck: Blossom server probes
- NoteActions: zap/lightning LNURL resolution
- DesktopBlossomClient: media uploads
- AccountManager: NIP-46 bunker relay connections
- Coil image loader: TODO (OkHttpNetworkFetcher import issue)
Also:
- Relay reconnect on Tor Active (prevents stale clearnet connections)
- isTorExpected() for fail-closed logic
- Tests updated for new torTypeProvider parameter
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Resolve LruCache vs LargeCache: use LargeCache (lock-free ConcurrentSkipListMap)
with BoundedLargeCache wrapper for size enforcement on put()
- Confirm LargeCache available on desktop via quartz jvmAndroid source set
- Detail FeedNoteCard rewrite: Note model field mapping, 5 subscription removals
- Fix filter examples to use filterIntoSet (LargeCache API)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
In multi-deck mode, Messages column now uses stacked navigation instead
of side-by-side split pane. Full-width contact list OR full-width chat —
clicking a conversation navigates to chat, back arrow returns to list.
Single-pane mode keeps the existing split layout (280dp list + flex chat).
Changes:
- Add compactMode param to DesktopMessagesScreen (default false)
- Extract SplitMessagesContent and CompactMessagesContent composables
- Add onBack callback to ChatPane with back arrow in header
- Remove hardcoded 280dp from ConversationListPane (caller controls width)
- Pass compactMode=true from deck RootContent
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add full send + receive encrypted media support in desktop DM chat:
- Paperclip attach button and drag-and-drop in ChatPane (NIP-17 mode only)
- AES-GCM encryption before upload to Blossom server
- ChatMessageEncryptedFileHeaderEvent (kind 15) wrapped in GiftWrap
- sendNip17EncryptedFile() added to IAccount interface and implementations
- DesktopUploadOrchestrator.uploadEncrypted() with proper encrypted hash
- DesktopBlossomClient ByteArray upload overload for encrypted blobs
- LRU cache in EncryptedMediaService to avoid re-downloading
- Error handling: retry on failure, disable send during upload
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Post type toggle (Note vs Picture/kind 20) in compose dialog, only
shown when image files are attached. Text input disabled in picture mode.
- Fix LazyVerticalGrid crash in GalleryTab: bounded height via
fillParentMaxHeight() when nested inside LazyColumn.
- Phase 1 testing plan: all pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When images are attached, a Note/Picture toggle appears letting the user
publish as kind 20 (PictureEvent) instead of kind 1. Text input is
disabled in picture mode. Selector only shows for image file types.
Updates testing plan: Phase 1 all pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Server selector dropdown appears when files are attached, letting the
user choose which Blossom server to upload to. Updates testing plan:
Phase 2 & 3 all pass, Phase 9 audio tested with volume bug tracked.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Passes :start-volume media option to VLC on play. Removes polling/delay
volume hacks. Documents 9.7 volume bug in testing plan — VLC ignores
initial volume on macOS, needs further investigation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>