mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 00:37:41 +00:00
Merge pull request #3618 from vitorpamplona/claude/amy-nip46-bunker-concord-epoch-diag
fix(nip46): remote-signer pubKey is the user identity + amy Concord epoch tooling
This commit is contained in:
@@ -221,7 +221,11 @@ class Context(
|
||||
secret = b.connectSecret,
|
||||
// Bunker requires web authorization: surface the URL; the request keeps waiting.
|
||||
onAuthUrl = { url -> System.err.println("[nip46] authorize this request in a browser, then it will continue:\n $url") },
|
||||
)
|
||||
).also {
|
||||
// signer.pubKey must be the USER identity, not the ephemeral transport key, so
|
||||
// self-encryption/decryption (Concord list, private NIP-51 lists) uses the right peer.
|
||||
it.bindUserPubkey(identity.pubKeyHex)
|
||||
}
|
||||
} ?: NostrSignerInternal(identity.keyPair())
|
||||
|
||||
/**
|
||||
|
||||
+21
-2
@@ -94,18 +94,37 @@ object ConcordChannelCommands {
|
||||
val limit = args.intFlag("limit", 50)
|
||||
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
|
||||
|
||||
// Diagnostic overrides (concord-epoch-walking-backfill): read a PRIOR epoch's Chat Plane by
|
||||
// supplying that epoch's community_root. A Refounding (CORD-06 §3) rotates the root and bumps
|
||||
// the epoch, so pre-refounding messages live under a different derived stream key that the
|
||||
// normal read (current epoch only) never fetches. Both derive from the same channel id, which
|
||||
// is epoch-invariant, so channel resolution stays on the current epoch below.
|
||||
val epoch = args.longFlag("epoch", sc.rootEpoch)
|
||||
// Resolve the root for that epoch: explicit --root wins; else the current root if --epoch is
|
||||
// the current epoch; else a stored heldRoot for that epoch (populated by `amy concord import`).
|
||||
val rootHex =
|
||||
args.flag("root")
|
||||
?: sc.root.takeIf { epoch == sc.rootEpoch }
|
||||
?: sc.heldRoots.firstOrNull { it.epoch == epoch }?.root
|
||||
?: return Output
|
||||
.error("not_found", "no root known for epoch $epoch — pass --root <hex> or run `amy concord import` to load heldRoots")
|
||||
.let { 1 }
|
||||
if (!HEX64.matches(rootHex)) return Output.error("bad_args", "--root must be a 64-char hex community_root").let { 2 }
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'")
|
||||
val channel = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelId.hexToByteArray(), sc.rootEpoch)
|
||||
val channel = ConcordActions.publicChannel(rootHex.hexToByteArray(), channelId.hexToByteArray(), epoch)
|
||||
val relays = ConcordCommands.relaysFor(ctx, sc)
|
||||
// The channel plane is NIP-42-gated to its own derived stream key; register it so the drain authenticates.
|
||||
ctx.registerConcordStreamKeys(relays, listOf(channel.secretKey))
|
||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(channel.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
val msgs = ConcordActions.channelMessages(wraps, channel, channelId, sc.rootEpoch).takeLast(limit)
|
||||
val msgs = ConcordActions.channelMessages(wraps, channel, channelId, epoch).takeLast(limit)
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"channel" to channelId,
|
||||
"epoch" to epoch,
|
||||
"plane" to channel.publicKeyHex,
|
||||
"count" to msgs.size,
|
||||
"messages" to msgs.map { mapOf("id" to it.id, "author" to it.author, "content" to it.content, "created_at" to it.createdAt) },
|
||||
),
|
||||
|
||||
@@ -26,8 +26,11 @@ import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.cli.stores.ConcordStore
|
||||
import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
|
||||
import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -45,10 +48,11 @@ object ConcordCommands {
|
||||
route(
|
||||
"concord",
|
||||
tail,
|
||||
"concord <create|list|channels|send|read|invite|join|roles|role|grant|ban|unban>",
|
||||
"concord <create|list|import|channels|send|read|invite|join|roles|role|grant|ban|unban>",
|
||||
mapOf(
|
||||
"create" to { rest -> create(dataDir, rest) },
|
||||
"list" to { rest -> list(dataDir, rest) },
|
||||
"import" to { rest -> import(dataDir, rest) },
|
||||
"channels" to { rest -> ConcordChannelCommands.channels(dataDir, rest) },
|
||||
"send" to { rest -> ConcordChannelCommands.send(dataDir, rest) },
|
||||
"read" to { rest -> ConcordChannelCommands.read(dataDir, rest) },
|
||||
@@ -117,6 +121,62 @@ object ConcordCommands {
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch this account's own encrypted kind-13302 Concord community list, decrypt it, and
|
||||
* upsert every community into the local store — crucially carrying each community's
|
||||
* `heldRoots` (the prior-epoch access roots Amethyst accumulates across Refoundings, CORD-06).
|
||||
* With those persisted, `amy concord read --epoch <n>` can re-derive a pre-refounding Chat
|
||||
* Plane. A fresh account (never lived through a Refounding) simply has empty `heldRoots`.
|
||||
*/
|
||||
private suspend fun import(
|
||||
dataDir: DataDir,
|
||||
@Suppress("UNUSED_PARAMETER") rest: Array<String>,
|
||||
): Int {
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val relays = (ctx.outboxRelays() + ctx.bootstrapRelays())
|
||||
val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(ctx.signer.pubKey))
|
||||
val events = ctx.drain(relays.associateWith { listOf(filter) }).map { it.second }
|
||||
val newest =
|
||||
events.filterIsInstance<ConcordCommunityListEvent>().maxByOrNull { it.createdAt }
|
||||
?: return Output.error("not_found", "no kind-13302 Concord list published by this account").let { 1 }
|
||||
|
||||
val entries =
|
||||
try {
|
||||
newest.decrypt(ctx.signer)
|
||||
} catch (e: Exception) {
|
||||
return Output.error("decrypt_failed", "could not decrypt kind-13302: ${e.message}").let { 1 }
|
||||
}
|
||||
val store = ConcordStore(dataDir.concordFile)
|
||||
val existing = store.load().associateBy { it.communityId }
|
||||
val imported =
|
||||
entries.map { e ->
|
||||
val prior = existing[e.id]
|
||||
store.upsert(
|
||||
StoredCommunity(
|
||||
name = e.name.ifBlank { prior?.name ?: "" },
|
||||
communityId = e.id,
|
||||
owner = e.owner,
|
||||
ownerSalt = e.ownerSalt,
|
||||
root = e.root,
|
||||
rootEpoch = e.rootEpoch,
|
||||
generalChannelId = prior?.generalChannelId ?: "",
|
||||
relays = e.relays,
|
||||
heldRoots = e.heldRoots.map { StoredHeldRoot(it.epoch, it.key) },
|
||||
),
|
||||
)
|
||||
mapOf(
|
||||
"name" to e.name,
|
||||
"community_id" to e.id,
|
||||
"root_epoch" to e.rootEpoch,
|
||||
"held_roots" to e.heldRoots.map { mapOf("epoch" to it.epoch, "root" to it.key) },
|
||||
)
|
||||
}
|
||||
Output.emit(mapOf("imported" to imported))
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun invite(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
|
||||
@@ -21,13 +21,17 @@
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Identity
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull
|
||||
import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
|
||||
import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
@@ -73,19 +77,53 @@ object LoginCommand {
|
||||
)
|
||||
|
||||
dataDir.saveIdentity(identity)
|
||||
|
||||
// For a bunker, the pubkey in the URI is the REMOTE SIGNER's key, which for many signer apps
|
||||
// (Amber, nsec.app) is a per-connection key distinct from the user's identity key. Resolve the
|
||||
// real identity via the NIP-46 get_public_key RPC and persist THAT (the bunker's remote key is
|
||||
// kept in Identity.bunker for transport addressing). Best-effort: if the bunker can't answer,
|
||||
// fall back to the URI pubkey so login still succeeds.
|
||||
val account = if (identity.bunker != null) resolveBunkerIdentity(dataDir, identity) else identity
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"npub" to identity.npub,
|
||||
"hex" to identity.pubKeyHex,
|
||||
"read_only" to !identity.canSign,
|
||||
"signer" to if (identity.bunker != null) "bunker" else "local",
|
||||
"bunker_relays" to identity.bunker?.relays,
|
||||
"npub" to account.npub,
|
||||
"hex" to account.pubKeyHex,
|
||||
"read_only" to !account.canSign,
|
||||
"signer" to if (account.bunker != null) "bunker" else "local",
|
||||
"bunker_relays" to account.bunker?.relays,
|
||||
"data_dir" to dataDir.root.absolutePath,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect the freshly-saved bunker and ask it (NIP-46 `get_public_key`) for the user's real
|
||||
* identity pubkey, re-persisting the [Identity] when it differs from the bunker's transport key.
|
||||
* Returns the corrected identity, or [provisional] unchanged if the RPC fails.
|
||||
*/
|
||||
private suspend fun resolveBunkerIdentity(
|
||||
dataDir: DataDir,
|
||||
provisional: Identity,
|
||||
): Identity =
|
||||
try {
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val real = (ctx.signer as NostrSignerRemote).getPublicKey().lowercase()
|
||||
if (real == provisional.pubKeyHex.lowercase()) {
|
||||
provisional
|
||||
} else {
|
||||
val corrected = provisional.copy(pubKeyHex = real, npub = real.hexToByteArray().toNpub())
|
||||
dataDir.saveIdentity(corrected)
|
||||
corrected
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
System.err.println("[nip46] could not resolve identity via get_public_key (${e.message}); using the bunker URI pubkey")
|
||||
provisional
|
||||
}
|
||||
|
||||
private suspend fun resolveIdentity(
|
||||
key: String,
|
||||
args: Args,
|
||||
|
||||
@@ -39,6 +39,15 @@ data class StoredCommunity(
|
||||
val rootEpoch: Long = 0,
|
||||
val generalChannelId: String = "",
|
||||
val relays: List<String> = emptyList(),
|
||||
// Past access roots kept per epoch (CORD-06 Refounding rotates the root). Lets `read --epoch <n>`
|
||||
// re-derive a prior epoch's Chat Plane to reach pre-refounding history. Populated by `import`.
|
||||
val heldRoots: List<StoredHeldRoot> = emptyList(),
|
||||
)
|
||||
|
||||
/** A past community_root for a specific epoch, mirroring quartz `HeldRoot`. */
|
||||
data class StoredHeldRoot(
|
||||
val epoch: Long = 0,
|
||||
val root: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
# Concord epoch-walking message backfill
|
||||
|
||||
**Date:** 2026-07-17
|
||||
**Module:** `commons` (drivers), with `quartz` primitives (present) + `amethyst` wiring
|
||||
**Status:** design / not started
|
||||
|
||||
## Problem
|
||||
|
||||
A Concord community that has been **refounded** (CORD-06 §3 — hard removal
|
||||
rotates `community_root` and bumps the epoch) loses all of its pre-refounding
|
||||
chat history from the client's view. Every channel message is encrypted under a
|
||||
plane stream key derived from the *root at the epoch it was authored under*
|
||||
(`publicChannel(community_root, channel_id, rootEpoch)`), and the message carries
|
||||
an `["epoch", n]` tag so cross-epoch replay is rejected
|
||||
(`ChannelChat.isBoundTo`). The client only ever derives, subscribes to, AUTHs
|
||||
as, and decrypts the plane at the **single current** `entry.rootEpoch`. So after
|
||||
a refounding the feed shows only messages authored since that refounding, then
|
||||
reports **"All caught up"** — which is literally correct *for the current
|
||||
epoch*, while months of history sit on the same relays under the previous
|
||||
epochs' stream keys, unfetched.
|
||||
|
||||
### Reproduced live (2026-07-17, `amy` vs the real Soapbox Community)
|
||||
|
||||
Joined the Armada invite and probed `relay.ditto.pub` + `relay.dreamith.to`:
|
||||
|
||||
| Fact | Value |
|
||||
|---|---|
|
||||
| `rootEpoch` (from invite bundle) | **2** — refounded ≥ once |
|
||||
| nostrhub @ epoch 2 | 2 messages, both authored **2026-07-17** |
|
||||
| general @ epoch 2 | 62 messages, **oldest 2026-07-16** |
|
||||
| ditto @ epoch 2 | 4 messages, **oldest 2026-07-16** |
|
||||
| agora @ epoch 2 | 3 messages, **oldest 2026-07-16** |
|
||||
|
||||
Every channel's history begins on 2026-07-16 (the refounding date). The app's
|
||||
"only 2 messages in nostrhub" reproduces exactly through the CLI. Not a relay,
|
||||
AUTH, or paging-cap issue — a structural epoch gap.
|
||||
|
||||
## Key finding: prior roots are already retained but never consumed
|
||||
|
||||
The hard part — keeping the old keys — **is already done**:
|
||||
|
||||
- `ConcordCommunityListEntry.heldRoots: List<HeldRoot(epoch, key)>`
|
||||
(`quartz/.../cord02Community/ConcordCommunityList.kt:31,60`, wire field
|
||||
`held_roots`). `HeldRoot`'s own KDoc: *"A past root key for a specific epoch,
|
||||
kept so historical channel keys stay derivable."*
|
||||
- `Account.adoptConcordRoot` (`amethyst/.../model/Account.kt:2513`) appends the
|
||||
outgoing `(rootEpoch, root)` to `heldRoots` on **every** rotation — both the
|
||||
refounder path (`refoundConcordCommunity`) and the receive path
|
||||
(`drainConcordRekeys` → `openBaseRekey`). It is persisted to the account's
|
||||
Concord list event.
|
||||
|
||||
But a repo-wide grep confirms **no code in the fetch / fold / subscribe path
|
||||
ever reads `heldRoots`.** `ConcordCommunitySession`, `ConcordSessionRegistry`,
|
||||
`ConcordSubscriptionPlanner`, and the amethyst filter assemblers all derive
|
||||
planes solely from `entry.rootEpoch` / `entry.root`. The retained roots are dead
|
||||
data. **This plan is almost entirely about consuming `heldRoots` on the read
|
||||
side** — the retention infrastructure the feature needs already exists.
|
||||
|
||||
### Scope boundary — who this helps
|
||||
|
||||
- **Members present across the refoundings** (have populated `heldRoots`): can
|
||||
fully backfill. Primary target.
|
||||
- **Fresh joiners via an invite** (empty `heldRoots`; the invite bundle carries
|
||||
only the current root): **cryptographically cannot** decrypt prior-epoch
|
||||
history — they were never given those roots, and the relays gate each epoch's
|
||||
kind-1059 behind AUTH-as-that-epoch's-stream-key. This is by design (a
|
||||
refounding is meant to sever access) and is **out of scope**. We must not try
|
||||
to work around it by stuffing old roots into invites — that would hand a
|
||||
brand-new member the keys a removal was meant to deny. If cross-refounding
|
||||
history for new joiners is ever wanted, it's a *protocol* change (Armada would
|
||||
re-publish compacted history under the new root), tracked separately.
|
||||
|
||||
## Design
|
||||
|
||||
`ConcordCommunitySession` is documented as "a pure function of its entry" and is
|
||||
rebuilt wholesale on rotation (`ConcordSessionRegistry.sync` replaces it when
|
||||
`root`/`rootEpoch` changes). Two ways to add historical epochs:
|
||||
|
||||
- **(A) Generalize the session to be multi-epoch** — derive a plane key set per
|
||||
`(epoch, root)` in `{current} ∪ heldRoots`, index buffered wraps and decrypt
|
||||
attempts across all of them.
|
||||
- **(B) Keep the current-epoch session as-is (live + write) and attach
|
||||
read-only "historical epoch readers"** — one lightweight derivation set per
|
||||
held root, contributing subscribe addresses + AUTH keys + decrypt attempts,
|
||||
but never used for writing/moderation/rekey.
|
||||
|
||||
**Recommend (B).** Writes, moderation, control-plane folding, rekey adoption,
|
||||
and Guestbook all must stay strictly on the current epoch — mixing historical
|
||||
roots into those paths risks authoring under a stale key or re-folding a
|
||||
superseded control plane. A read-only historical layer keeps the blast radius to
|
||||
message fetch/decrypt. The channel id is **epoch-invariant** (`ConcordChannelKeys`
|
||||
KDoc: "stays constant across visibility conversions and epoch rotations"), so a
|
||||
decrypted historical message lands in the *same* channel feed as current ones —
|
||||
no feed-merge logic needed; `LocalCache`/the gatherer keys on channel id.
|
||||
|
||||
### New concept: `EpochPlaneSet`
|
||||
|
||||
A small value type: for one `(epoch, root)`, derive the control-plane key and,
|
||||
given the current folded channel-id list, the per-channel `publicChannel` keys.
|
||||
The current epoch already computes this inline in `ConcordCommunitySession`;
|
||||
factor the derivation into a reusable helper so current + historical share it.
|
||||
|
||||
> Channel *membership* comes from folding the **current** control plane (channels
|
||||
> aren't re-listed per epoch). We assume the channel-id set is stable across the
|
||||
> covered epochs (channels created after an old epoch simply have no messages
|
||||
> there → empty, harmless). Deleted/renamed channels: the id persists, so old
|
||||
> messages still decrypt. Private channels: use `entry.privateChannels`
|
||||
> (`PrivateChannelKey` already carries a per-`epoch` key) instead of the root.
|
||||
|
||||
## Component-by-component changes
|
||||
|
||||
### 1. `quartz` — none required (primitives already exist)
|
||||
`ConcordActions.publicChannel/controlPlane/channelRumors/channelMessages` and
|
||||
`ChannelChat.isBoundTo` already take an explicit `epoch`. Reuse verbatim.
|
||||
|
||||
### 2. `commons/.../model/concord/ConcordCommunitySession.kt`
|
||||
- Build historical `EpochPlaneSet`s from `entry.heldRoots` (bounded — see
|
||||
§Bounding) alongside the existing current-epoch derivation.
|
||||
- `channelAddresses()` → also emit each historical channel plane pubkey so the
|
||||
planner subscribes to them.
|
||||
- `streamKeys()` → include historical control + channel `GroupKey`s so the
|
||||
NIP-42 AUTH set authenticates as each prior epoch's stream key (this is what
|
||||
unlocks the gated relays for old wraps). Keep the aux (Guestbook / next-rekey)
|
||||
isolation rule intact.
|
||||
- `ingest(wrap)` → currently matches a wrap by its plane address against the
|
||||
current-epoch address map. Extend the address→(channelId, key, **epoch**) map
|
||||
to include historical entries; on match, decrypt with that epoch's key and
|
||||
validate `isBoundTo(rumor, channelId, thatEpoch)`. Emit the rumor exactly as
|
||||
today (same `onRumor` sink → same channel feed).
|
||||
- Historical wraps feed `observedAuthors` too — a nice side effect: the member
|
||||
roster harvest gets the full-history posters for free.
|
||||
- Do **not** route historical wraps into `refold()` (control plane),
|
||||
`refoldGuestbook()`, or rekey buffers — read path only.
|
||||
|
||||
### 3. `commons/.../model/concord/ConcordSessionRegistry.kt`
|
||||
`subscribeAddresses()` already unions `session.channelAddresses()`; once the
|
||||
session emits historical addresses it flows through unchanged. Verify no other
|
||||
call site assumes one-address-per-channel.
|
||||
|
||||
### 4. `commons/.../actions/ConcordSubscriptionPlanner.kt`
|
||||
`channelPlaneSubs()` derives `publicChannel(root, channelId, entry.rootEpoch)`.
|
||||
Generalize to emit a plane sub per `(channel, epoch)` across the covered epoch
|
||||
set, collapsed into the existing `{kinds:[1059,21059], authors:[…]}` batching in
|
||||
`relayBasedFilters()`. The historical subs can be one-shot (no live tail needed —
|
||||
old epochs are frozen), so consider a bounded `until`-less REQ that EOSEs rather
|
||||
than a standing subscription, to cap connection cost.
|
||||
|
||||
### 5. `amethyst/.../concord/datasource/` filter assemblers
|
||||
- `ConcordChannelHistoryFilterAssembler` (`BackwardRelayPager`) — today pages one
|
||||
plane pubkey (`session.channelPlaneAddress(channelId)`, current epoch). The
|
||||
pager must **step to the previous epoch's plane pubkey when the current epoch
|
||||
is exhausted** rather than declaring `PagingStatus.exhausted`. Options:
|
||||
(a) page all epoch planes concurrently and only report exhausted when every
|
||||
epoch's relays are done; (b) sequential — walk newest→oldest epoch. (a) is
|
||||
simpler to reason about with the existing per-(uniqueId,relay) EOSE tracking;
|
||||
(b) gives cleaner "load older" UX. Prefer (a).
|
||||
- `ConcordChannelFilterAssembler` (live tail) — historical planes need no live
|
||||
tail; only the current epoch keeps a standing sub.
|
||||
- **"All caught up"** (`ConcordChannelScreen.kt:199` on `historyStatus.exhausted`)
|
||||
becomes correct once exhaustion means "all covered epochs drained," not "the
|
||||
current epoch drained."
|
||||
|
||||
### 6. AUTH — register historical stream keys
|
||||
`ConcordSessionManager.streamAuthSecretsFor(relay)` derives from
|
||||
`session.streamKeys()`; once that includes historical keys, `AuthCoordinator`
|
||||
signs one kind-22242 per prior-epoch stream key and the gated relays serve the
|
||||
old wraps. Watch the `RelayAuthStatus` LruCache size (widened 10→200 for the
|
||||
current-epoch multi-identity work) — N epochs × M channels can exceed 200; size
|
||||
it to `epochs × (channels + 1)` with headroom.
|
||||
|
||||
### 7. `cli` — diagnostics (`ConcordChannelCommands.read`) — **DONE**
|
||||
`StoredCommunity` has no `heldRoots`, and a fresh `amy concord join` can't obtain
|
||||
them — so amy can't self-serve a member's history. Landed:
|
||||
- `amy concord read <community> <channel> --epoch <n> --root <hex>` — derives the
|
||||
Chat Plane at an explicitly supplied `(epoch, root)` and drains it (both flags
|
||||
default to the stored current epoch/root; channel-id resolution stays on the
|
||||
current epoch since ids are epoch-invariant). Output now also emits `epoch` and
|
||||
the derived `plane` pubkey. Verified: explicit `--epoch 2 --root <current>`
|
||||
reproduces the stored plane pubkey byte-for-byte; each epoch derives a distinct
|
||||
plane; a non-hex `--root` errors `bad_args`/exit 2. Confirms old-epoch wraps can
|
||||
be probed once a prior root is known.
|
||||
- **DONE:** `StoredCommunity.heldRoots` + `amy concord import` — fetches this
|
||||
account's own encrypted kind-13302 `ConcordCommunityListEvent`, decrypts it
|
||||
with the account signer, and upserts every community **including its
|
||||
`heldRoots`** (the prior-epoch access roots Amethyst persists in that same
|
||||
published event via `Account.adoptConcordRoot`). `read --epoch <n>` then
|
||||
auto-resolves the root for that epoch from the stored `heldRoots` (explicit
|
||||
`--root` still wins). So a member who lived through the Refoundings can:
|
||||
`amy concord import` → `amy concord read <community> <channel> --epoch <n>`
|
||||
and reach pre-refounding history without knowing the raw prior roots. A fresh
|
||||
account simply imports empty `heldRoots` (nothing to recover — the expected
|
||||
cryptographic wall). Import + decrypt are **read-only** (no publish).
|
||||
|
||||
## Bounding (cost control)
|
||||
|
||||
Each covered epoch multiplies the subscription/AUTH footprint by
|
||||
`(channels + 1)` stream keys. Bound it:
|
||||
- **Config:** `CONCORD_BACKFILL_EPOCHS` (default: all held — the list is small in
|
||||
practice; refoundings are rare). If a community is refounded often, cap to the
|
||||
N most recent held epochs.
|
||||
- **Time window:** the member-roster harvest already bounds to
|
||||
`now − 90d` (`ConcordMemberHarvest`, `CONCORD_MEMBER_HARVEST_WINDOW_SECS`).
|
||||
The *interactive* channel backfill should be user-driven (paged on scroll, no
|
||||
`since` floor) so a member can reach the true beginning; the *background*
|
||||
harvest keeps its window.
|
||||
- Historical planes are frozen → prefer one-shot EOSE REQs over standing subs to
|
||||
avoid holding N× subscriptions open forever.
|
||||
|
||||
## Testing
|
||||
|
||||
- **quartz** — none new (primitives unchanged); existing epoch/`isBoundTo` tests
|
||||
cover the binding.
|
||||
- **commons unit** (`ConcordCommunitySessionTest`, `ConcordSubscriptionPlannerTest`,
|
||||
`ConcordSessionRegistryTest`):
|
||||
- Build an entry with `heldRoots = [(epoch0,rootA),(epoch1,rootB)]`, current
|
||||
epoch 2/rootC. Assert `channelAddresses()`/`streamKeys()`/planner subs emit
|
||||
a plane per `(channel, epoch)` across all three.
|
||||
- Feed the session wraps authored under each historical key; assert the rumor
|
||||
is decrypted with the *matching* epoch key, `isBoundTo` passes, and it
|
||||
reaches the `onRumor` sink; a wrap whose epoch tag ≠ its plane epoch is
|
||||
dropped.
|
||||
- Assert historical wraps do **not** enter `refold()`/control state.
|
||||
- **commons paging** (`BackwardRelayPagerTest`): a multi-epoch channel reports
|
||||
`exhausted` only after every epoch's relays EOSE on an empty page.
|
||||
- **Live, via `amy`**: use the new `--epoch/--root` diag against Soapbox once a
|
||||
prior Soapbox root is available (ask maintainer / capture from an account that
|
||||
lived through the refounding) → confirm epoch-1 nostrhub wraps decrypt.
|
||||
|
||||
## Risks / open questions
|
||||
|
||||
1. **Fresh joiners still see nothing pre-refounding** — inherent, documented
|
||||
above. UI could show a "History before <date> requires having been a member"
|
||||
affordance instead of a bare "All caught up," so it doesn't read as a bug.
|
||||
2. **AUTH fan-out on gated relays** — N epochs × M channels AUTH events on one
|
||||
connection. Current-epoch work already accumulates multiple identities on one
|
||||
connection successfully; validate it scales (LruCache sizing, relay
|
||||
per-connection AUTH limits). Fall back to bounding epochs if a relay balks.
|
||||
3. **`heldRoots` completeness** — only populated from the moment the account
|
||||
started adopting rotations. A member who joined at epoch 2 has no epoch-0/1
|
||||
roots even if present later; nothing to do — same cryptographic limit.
|
||||
4. **Private channels across epochs** — `PrivateChannelKey.epoch` exists, but
|
||||
verify a private channel's key was actually re-delivered per epoch (rotated on
|
||||
revocation); if a member missed an epoch's private key, that epoch of that
|
||||
channel is unreadable (expected).
|
||||
5. **Standing-sub vs one-shot for history** — decide before wiring the planner;
|
||||
affects connection budget on the audio-room-heavy relay set.
|
||||
|
||||
## Suggested sequence
|
||||
|
||||
1. Factor `EpochPlaneSet` derivation + make `ConcordCommunitySession` emit
|
||||
historical addresses/keys/decrypt (commons unit-tested in isolation — no
|
||||
network). Ship behind a flag defaulting off.
|
||||
2. Planner + AUTH wiring; commons tests.
|
||||
3. amethyst `BackwardRelayPager` epoch-stepping + "All caught up" semantics.
|
||||
4. ~~`amy --epoch/--root` diagnostic~~ **DONE** (§7); still need a real prior
|
||||
Soapbox root to validate old-epoch decrypt end-to-end.
|
||||
5. Flip the flag on; on-device verify on a refounded community.
|
||||
+5
@@ -346,6 +346,11 @@ class AccountManager internal constructor(
|
||||
remoteSigner.getPublicKey()
|
||||
}
|
||||
|
||||
// Bind the identity so signer.pubKey is the USER key, not the ephemeral transport key —
|
||||
// otherwise self-encryption (private NIP-51 lists, drafts, …) keys off the wrong pubkey.
|
||||
// Idempotent with the getPublicKey() branch above, which already caches the same value.
|
||||
remoteSigner.bindUserPubkey(pubKeyHex)
|
||||
|
||||
val resolvedNpub = npub ?: pubKeyHex.hexToByteArray().toNpub()
|
||||
|
||||
val state =
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# NIP-46 remote signer: `pubKey` must be the user identity, not the transport key
|
||||
|
||||
**Date:** 2026-07-17
|
||||
**Module:** `quartz` (core fix) + `desktopApp` / `cli` (affected front ends)
|
||||
**Status:** design / not started
|
||||
**Severity:** correctness — private data invisible/corrupted for **bunker (NIP-46) accounts** on desktop + CLI. Android unaffected.
|
||||
|
||||
## The bug
|
||||
|
||||
`NostrSignerRemote` extends `NostrSigner(signer.pubKey)`
|
||||
(`quartz/.../nip46RemoteSigner/signer/NostrSignerRemote.kt:54-72`), where the
|
||||
constructor arg `signer: NostrSignerInternal` is the **ephemeral NIP-46
|
||||
transport keypair**. `NostrSigner.pubKey` is a plain `val` never reassigned. So
|
||||
for a bunker account:
|
||||
|
||||
```
|
||||
NostrSignerRemote.pubKey == transport key T (ephemeral, per-connection)
|
||||
the user's real identity == user key U (only via get_public_key RPC)
|
||||
events the bunker signs have pubKey == U
|
||||
```
|
||||
|
||||
Every site that treats `signer.pubKey` as "myself" therefore uses **T instead of
|
||||
U**. Two failure modes:
|
||||
|
||||
### Mode A — authorship guard short-circuits (unconditional failure)
|
||||
Sites that gate on `signer.pubKey == event.pubKey`. Event was signed by the
|
||||
bunker (`pubKey == U`), `signer.pubKey == T`, so the guard blocks decryption
|
||||
before any key math:
|
||||
- `nip51Lists/PrivateTagArrayEvent.kt:44-66` — `decrypt()` throws
|
||||
`UnauthorizedDecryptionException`; `privateTags()` returns `null`.
|
||||
- `nip51Lists/PrivateReplaceableTagArrayEvent.kt:44-53` — same guard.
|
||||
- `nip37Drafts/DraftWrapEvent.kt:52` — `canDecrypt = signer.pubKey == pubKey`
|
||||
→ always false.
|
||||
|
||||
Covers **all private NIP-51 lists** (private bookmarks, the private half of the
|
||||
mute list, private follows/people lists, private hashtag/geohash lists…) and
|
||||
**NIP-37 drafts**. Fails even for data Amethyst itself wrote through the bunker
|
||||
(written as U, read back with T). Independent of key-stability nuances.
|
||||
|
||||
### Mode B — wrong self-peer / wrong author filter (no guard)
|
||||
Sites that self-encrypt directly to `signer.pubKey`:
|
||||
- `nip51Lists/encryption/PrivateTagsInContent.kt:41,57,64,74` — the crypto under
|
||||
the guarded lists (`nip44Encrypt(…, signer.pubKey)` / `decrypt(…, signer.pubKey)`).
|
||||
- `concord/cord02Community/ConcordCommunityListEvent.kt:56,74` +
|
||||
`ConcordCommunityList.kt:170,222` — Concord list self-encryption.
|
||||
- `nip60Cashu/token/CashuTokenEvent.kt:74`, `quote/CashuMintQuoteEvent.kt:136`.
|
||||
- `amethyst/.../nip78AppSpecific/AppSpecificState.kt:60` (Android-only file → moot).
|
||||
|
||||
The bunker derives ECDH(u, T) instead of ECDH(u, U): standard-client content
|
||||
(peer U) fails MAC; content Amethyst writes is sealed to peer T, unreadable by
|
||||
every other client and by the same user's local-key login. Author filters like
|
||||
`authors = listOf(signer.pubKey)` also query T, so the user's own U-authored
|
||||
events are never fetched (e.g. `Account.importConcordCommunities` filter, the
|
||||
CLI `concord list`).
|
||||
|
||||
## Impact by front end (verified)
|
||||
|
||||
- **Android `amethyst/`: UNAFFECTED.** No `NostrSignerRemote` is ever
|
||||
constructed (`AccountCacheState.loadAccount` builds only `NostrSignerInternal`
|
||||
/ `NostrSignerExternal`; the only `NostrSignerRemote` reference is a type-check
|
||||
in `MeteringNostrSigner.kt:122`). Android has no bunker login, so
|
||||
`signer.pubKey == account pubkey` always.
|
||||
- **Desktop `desktopApp/`: AFFECTED.** Bunker login is a first-class path
|
||||
(`AccountManager.loginWithBunker` / `loginWithNostrConnect`). Confirmed live:
|
||||
`BookmarksScreen.kt:189,355` → `list.privateBookmarks(account.signer)` → guard
|
||||
returns null → **private bookmarks always empty** for bunker accounts. Same
|
||||
guard breaks the private mute section, private follow lists, and drafts. Cashu
|
||||
self-encryption sealed to the wrong peer. (No Concord feature on desktop.)
|
||||
- **CLI `cli/`: AFFECTED.** Same failures; `concord list` breaks on both the
|
||||
author filter and the decrypt. The `amy concord import`/`read` diagnostics
|
||||
(2026-07-17) already work around it by using `ctx.identity.pubKeyHex` — but
|
||||
every other self-encryption site in the CLI is still wrong.
|
||||
|
||||
Transport key is **persisted + stable across restarts** on both desktop and CLI,
|
||||
so Mode-B data *round-trips within one install* (looks fine locally) but is
|
||||
non-portable and non-standard; a fresh `nostrconnect://` session regenerating
|
||||
the key makes it permanently unrecoverable. Mode-A fails regardless.
|
||||
|
||||
## Fix
|
||||
|
||||
**Core (quartz):** make `NostrSignerRemote.pubKey` return the **verified user
|
||||
key U**, not the transport key T. Constraints:
|
||||
- The user key is known only after the `get_public_key` RPC, so it can't be set
|
||||
from the raw `bunker://` parts at construction. Resolve it during the
|
||||
login/connect handshake (desktop `NostrConnectLoginUseCase` and CLI login
|
||||
already call `getPublicKey()`), and construct/finalize the signer with U as its
|
||||
identity.
|
||||
- **Keep the transport key for NIP-46 transport.** Internal sites that legitimately
|
||||
need T — the response subscription filter `p: signer.pubKey`
|
||||
(`NostrSignerRemote.kt:91`), request addressing/encryption to the bunker
|
||||
(`RemoteSignerManager`, `remoteKey = remotePubkey`) — must reference the
|
||||
transport keypair explicitly (`this.signer.pubKey`), **not** the base-class
|
||||
`pubKey`. Audit every `signer.pubKey`/`pubKey` use inside `NostrSignerRemote`
|
||||
and `RemoteSignerManager` and pin transport uses to the transport keypair
|
||||
before flipping the base `pubKey`.
|
||||
|
||||
Two implementation options:
|
||||
1. **Explicit identity param** — add `userPubkey: HexKey` to `NostrSignerRemote`
|
||||
(or a factory that RPCs `get_public_key`, then builds the signer with U as
|
||||
`NostrSigner(userPubkey)`). Cleanest; makes the contract explicit. Requires
|
||||
touching every construction site (desktop `AccountManager`, CLI `Context`,
|
||||
`NostrConnectLoginUseCase`).
|
||||
2. **Late-resolved pubKey** — allow the base identity to be set once after the
|
||||
connect handshake. Smaller call-site churn, but `NostrSigner.pubKey` becoming
|
||||
non-`val` ripples widely; less desirable.
|
||||
|
||||
Prefer **(1)**.
|
||||
|
||||
For local (`NostrSignerInternal`) and external NIP-55 (`NostrSignerExternal`)
|
||||
signers, `signer.pubKey` already equals the account key, so the change is a
|
||||
**no-op** for them — only bunker accounts change behavior. That keeps the blast
|
||||
radius to exactly the broken case.
|
||||
|
||||
Once `NostrSignerRemote.pubKey == U`, all Mode-A guards pass and all Mode-B
|
||||
self-encryption uses the right peer; **amy can drop its manual-decrypt
|
||||
workaround** and the `Account.importConcordCommunities` filter/decrypt become
|
||||
correct for any future bunker use.
|
||||
|
||||
## Migration / data caveat
|
||||
|
||||
Any Mode-B data a bunker user *already wrote* was sealed to peer T. After the
|
||||
fix (peer U) it becomes unreadable — but it was already unreadable everywhere
|
||||
except that one install, so the fix trades a hidden-corruption state for a
|
||||
correct one. Private NIP-51 lists / drafts (Mode-A) were never successfully
|
||||
written wrong (the guard blocked the write path's read-modify-write too), so
|
||||
there's nothing to migrate there — they simply start working. Call this out in
|
||||
the PR; no migration code needed, but a note for affected desktop users is kind.
|
||||
|
||||
## Testing
|
||||
|
||||
- **quartz unit:** construct a `NostrSignerRemote` whose transport key ≠ user
|
||||
key; assert `pubKey == userKey`; assert `PrivateTagArrayEvent.decrypt` /
|
||||
`DraftWrapEvent.canDecrypt` succeed against a U-authored event; assert the
|
||||
NIP-46 response subscription still filters on the transport key.
|
||||
- **round-trip:** self-encrypt a private list with the remote signer, decrypt
|
||||
with a *local* `NostrSignerInternal` for U → must match (proves portability).
|
||||
- **desktop:** bunker login → private bookmarks / private mute / drafts render.
|
||||
- **CLI:** `amy concord list` (not just `import`) loads communities for a bunker
|
||||
account; drop the `import` workaround and confirm `newest.decrypt(signer)`
|
||||
works.
|
||||
|
||||
## Suggested sequence
|
||||
|
||||
1. quartz: audit transport-vs-identity `pubKey` uses inside
|
||||
`NostrSignerRemote`/`RemoteSignerManager`; pin transport uses to the transport
|
||||
keypair.
|
||||
2. quartz: add the explicit-identity construction (option 1) + unit tests.
|
||||
3. desktop `AccountManager` + `NostrConnectLoginUseCase`: pass the verified U
|
||||
into the signer; verify private lists on-device.
|
||||
4. CLI `Context`: pass `identity.pubKeyHex` as the signer identity; drop the amy
|
||||
Concord decrypt workaround.
|
||||
+8
-1
@@ -27,8 +27,15 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
|
||||
abstract class NostrSigner(
|
||||
val pubKey: HexKey,
|
||||
pubKey: HexKey,
|
||||
) {
|
||||
/**
|
||||
* The account's own identity pubkey. `open` because a NIP-46 remote signer resolves it
|
||||
* from the bunker (`get_public_key`) rather than from a local key it holds — see
|
||||
* `NostrSignerRemote`, whose transport keypair is deliberately NOT the user identity.
|
||||
*/
|
||||
open val pubKey: HexKey = pubKey
|
||||
|
||||
abstract fun isWriteable(): Boolean
|
||||
|
||||
suspend fun <T : Event> sign(ev: EventTemplate<T>): T = sign(ev.createdAt, ev.kind, ev.tags, ev.content)
|
||||
|
||||
+27
@@ -70,6 +70,31 @@ class NostrSignerRemote(
|
||||
*/
|
||||
val onAuthUrl: ((String) -> Unit)? = null,
|
||||
) : NostrSigner(signer.pubKey) {
|
||||
// The user's real identity, resolved from the bunker via `get_public_key`. The constructor
|
||||
// `signer` is the ephemeral NIP-46 TRANSPORT keypair, NOT the user — so until this is bound,
|
||||
// `pubKey` falls back to the transport key. Every self-encryption / self-authorship site keys
|
||||
// off `pubKey`, so leaving it as the transport key silently breaks private NIP-51 lists, NIP-37
|
||||
// drafts, Concord list decryption, etc. for bunker accounts. Bound either eagerly from a saved
|
||||
// identity ([bindUserPubkey]) or lazily by the first [getPublicKey] call.
|
||||
private var resolvedUserPubkey: HexKey? = null
|
||||
|
||||
/**
|
||||
* The account identity. Returns the bunker-resolved user key once known, else the transport
|
||||
* key. Internal NIP-46 transport (the response-subscription `p` filter, request addressing)
|
||||
* deliberately uses `signer.pubKey`/`remotePubkey` directly and is unaffected by this.
|
||||
*/
|
||||
override val pubKey: HexKey
|
||||
get() = resolvedUserPubkey ?: signer.pubKey
|
||||
|
||||
/**
|
||||
* Bind the user's identity pubkey when it is already known (e.g. a persisted bunker account
|
||||
* reloaded from disk, or the CLI's stored identity) so `pubKey` is correct without a
|
||||
* `get_public_key` round-trip.
|
||||
*/
|
||||
fun bindUserPubkey(userPubkey: HexKey) {
|
||||
resolvedUserPubkey = userPubkey
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
private val manager =
|
||||
@@ -292,6 +317,8 @@ class NostrSignerRemote(
|
||||
)
|
||||
|
||||
if (result is SignerResult.RequestAddressed.Successful<PublicKeyResult>) {
|
||||
// Cache it so `pubKey` reflects the real identity from here on (self-encryption etc.).
|
||||
resolvedUserPubkey = result.result.pubkey
|
||||
return result.result.pubkey
|
||||
}
|
||||
|
||||
|
||||
+26
@@ -119,6 +119,32 @@ class NostrSignerRemoteIsolationTest {
|
||||
private val generalRelay = NormalizedRelayUrl("wss://relay.damus.io/")
|
||||
private val validHex = "a".repeat(64)
|
||||
|
||||
@Test
|
||||
fun pubKeyFallsBackToTransportKeyUntilBoundThenReturnsUserIdentity() {
|
||||
val trackingClient = TrackingNostrClient()
|
||||
val ephemeralSigner = NostrSignerInternal(KeyPair())
|
||||
val userIdentity = "b".repeat(64)
|
||||
|
||||
val remote =
|
||||
NostrSignerRemote(
|
||||
signer = ephemeralSigner,
|
||||
remotePubkey = validHex,
|
||||
relays = setOf(bunkerRelay),
|
||||
client = trackingClient,
|
||||
)
|
||||
|
||||
// Before the user identity is known, pubKey is the ephemeral transport key — NOT the
|
||||
// remotePubkey (which is the bunker's addressing key) and not yet the user's identity.
|
||||
assertEquals(ephemeralSigner.pubKey, remote.pubKey)
|
||||
|
||||
// Once bound (mirrors a reloaded account / a cached get_public_key), pubKey is the user key.
|
||||
remote.bindUserPubkey(userIdentity)
|
||||
assertEquals(userIdentity, remote.pubKey)
|
||||
|
||||
// The transport keypair used for NIP-46 addressing is untouched by the binding.
|
||||
assertEquals(ephemeralSigner.pubKey, remote.signer.pubKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscriptionFilterTargetsOnlyBunkerRelays() {
|
||||
val trackingClient = TrackingNostrClient()
|
||||
|
||||
Reference in New Issue
Block a user