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:
Vitor Pamplona
2026-07-17 17:25:39 -04:00
committed by GitHub
11 changed files with 613 additions and 10 deletions
@@ -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())
/**
@@ -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 = "",
)
/**