mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +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 = "",
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user