[codex] Prevent actor-tag UI impersonation (#1931)

Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Jordan Mecom
2026-07-16 12:07:26 -07:00
committed by GitHub
parent 73b885c5d7
commit c540ec9678
26 changed files with 591 additions and 188 deletions

View File

@@ -703,10 +703,12 @@ fn count_e_tags(event: &Event) -> usize {
.count()
}
/// Extract the effective author of a stored event (handles relay-signed REST events).
/// Extract the effective author of a stored event (handles workflow-generated and
/// legacy relay-signed attributed events).
pub(crate) fn effective_message_author(event: &Event, relay_pubkey: &nostr::PublicKey) -> Vec<u8> {
if event.pubkey == *relay_pubkey {
// Relay-signed REST event — real author in "actor" or "p" tag.
// Workflow-generated or legacy relay-signed attributed event — real author
// in "actor" or "p" tag.
if let Some(hex) = event.tags.iter().find_map(|t| {
if t.kind().to_string() == "actor" {
t.content().map(|s| s.to_string())

View File

@@ -1,6 +1,10 @@
use nostr::{Event, EventId, Keys, PublicKey};
use tauri::{AppHandle, State};
mod forum;
use forum::{forum_message_from_event, forum_reply_from_event};
use crate::{
app_state::AppState,
events,
@@ -8,7 +12,7 @@ use crate::{
models::{
FeedItemInfo, FeedMeta, FeedResponse, FeedSections, ForumMessageInfo, ForumPostsResponse,
ForumThreadReplyInfo, ForumThreadResponse, SearchResponse, SendChannelMessageResponse,
ThreadRepliesResponse, ThreadSummary,
ThreadRepliesResponse,
},
nostr_convert,
relay::{query_relay, submit_event, submit_event_with_keys},
@@ -1017,65 +1021,3 @@ fn feed_item_from_event(ev: &nostr::Event, category: &str) -> FeedItemInfo {
category: category.to_string(),
}
}
fn forum_message_from_event(ev: &nostr::Event, channel_id: &str) -> ForumMessageInfo {
ForumMessageInfo {
event_id: ev.id.to_hex(),
pubkey: ev.pubkey.to_hex(),
content: ev.content.clone(),
kind: ev.kind.as_u16() as u32,
created_at: ev.created_at.as_secs() as i64,
channel_id: channel_id.to_string(),
tags: tags_to_vec(ev),
thread_summary: Some(ThreadSummary {
reply_count: 0,
descendant_count: 0,
last_reply_at: None,
participants: Vec::new(),
}),
reactions: serde_json::Value::Null,
}
}
fn forum_reply_from_event(
ev: &nostr::Event,
channel_id: &str,
root_event_id: &str,
) -> ForumThreadReplyInfo {
// Walk e-tags for NIP-10 parent/root markers.
let (mut parent_id, mut explicit_root) = (None, None);
for t in ev.tags.iter() {
let s = t.as_slice();
if s.len() >= 2 && s[0] == "e" {
match s.get(3).map(|x| x.as_str()) {
Some("root") => explicit_root = Some(s[1].clone()),
Some("reply") => parent_id = Some(s[1].clone()),
_ => {
if parent_id.is_none() {
parent_id = Some(s[1].clone());
}
}
}
}
}
let parent = parent_id
.clone()
.unwrap_or_else(|| root_event_id.to_string());
let root = explicit_root.unwrap_or_else(|| root_event_id.to_string());
let depth = if parent == root { 1 } else { 2 };
ForumThreadReplyInfo {
event_id: ev.id.to_hex(),
pubkey: ev.pubkey.to_hex(),
content: ev.content.clone(),
kind: ev.kind.as_u16() as u32,
created_at: ev.created_at.as_secs() as i64,
channel_id: channel_id.to_string(),
tags: tags_to_vec(ev),
parent_event_id: Some(parent),
root_event_id: Some(root),
depth,
broadcast: false,
reactions: serde_json::Value::Null,
}
}

View File

@@ -0,0 +1,70 @@
use crate::models::{ForumMessageInfo, ForumThreadReplyInfo, ThreadSummary};
fn tags_to_vec(event: &nostr::Event) -> Vec<Vec<String>> {
event
.tags
.iter()
.map(|tag| tag.as_slice().to_vec())
.collect()
}
pub(super) fn forum_message_from_event(event: &nostr::Event, channel_id: &str) -> ForumMessageInfo {
ForumMessageInfo {
event_id: event.id.to_hex(),
pubkey: event.pubkey.to_hex(),
sig: event.sig.to_string(),
content: event.content.clone(),
kind: event.kind.as_u16() as u32,
created_at: event.created_at.as_secs() as i64,
channel_id: channel_id.to_string(),
tags: tags_to_vec(event),
thread_summary: Some(ThreadSummary {
reply_count: 0,
descendant_count: 0,
last_reply_at: None,
participants: Vec::new(),
}),
reactions: serde_json::Value::Null,
}
}
pub(super) fn forum_reply_from_event(
event: &nostr::Event,
channel_id: &str,
root_event_id: &str,
) -> ForumThreadReplyInfo {
let (mut parent_id, mut explicit_root) = (None, None);
for tag in event.tags.iter() {
let values = tag.as_slice();
if values.len() >= 2 && values[0] == "e" {
match values.get(3).map(String::as_str) {
Some("root") => explicit_root = Some(values[1].clone()),
Some("reply") => parent_id = Some(values[1].clone()),
_ if parent_id.is_none() => parent_id = Some(values[1].clone()),
_ => {}
}
}
}
let parent = parent_id
.clone()
.unwrap_or_else(|| root_event_id.to_string());
let root = explicit_root.unwrap_or_else(|| root_event_id.to_string());
let depth = if parent == root { 1 } else { 2 };
ForumThreadReplyInfo {
event_id: event.id.to_hex(),
pubkey: event.pubkey.to_hex(),
sig: event.sig.to_string(),
content: event.content.clone(),
kind: event.kind.as_u16() as u32,
created_at: event.created_at.as_secs() as i64,
channel_id: channel_id.to_string(),
tags: tags_to_vec(event),
parent_event_id: Some(parent),
root_event_id: Some(root),
depth,
broadcast: false,
reactions: serde_json::Value::Null,
}
}

View File

@@ -251,6 +251,7 @@ pub struct ThreadSummary {
pub struct ForumMessageInfo {
pub event_id: String,
pub pubkey: String,
pub sig: String,
pub content: String,
pub kind: u32,
pub created_at: i64,
@@ -272,6 +273,7 @@ pub struct ForumPostsResponse {
pub struct ForumThreadReplyInfo {
pub event_id: String,
pub pubkey: String,
pub sig: String,
pub content: String,
pub kind: u32,
pub created_at: i64,

View File

@@ -4,6 +4,7 @@ import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader";
import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers";
import { useMessageEventProfilePubkeys } from "@/features/channels/useMessageEventProfilePubkeys";
import { useThreadTargetSync } from "@/features/channels/useThreadTargetSync";
import {
useChannelMembersQuery,
@@ -37,12 +38,7 @@ import {
useSendMessageMutation,
useToggleReactionMutation,
} from "@/features/messages/hooks";
import {
collectMessageAuthorPubkeys,
collectMessageMentionPubkeys,
collectReactionActorPubkeys,
formatTimelineMessages,
} from "@/features/messages/lib/formatTimelineMessages";
import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages";
import {
channelWindowThreadSummaries,
type ChannelWindowThreadSummary,
@@ -59,6 +55,7 @@ import { useThreadReplies } from "@/features/messages/useThreadReplies";
import { useChannelTyping } from "@/features/messages/useChannelTyping";
import type { TimelineMessage } from "@/features/messages/types";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { useRelaySelfQuery } from "@/features/moderation/hooks";
import type { RelayEvent, RespondToMode, SearchHit } from "@/shared/api/types";
import { useChannelFind } from "@/features/search/useChannelFind";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
@@ -158,6 +155,7 @@ export function ChannelScreen({
const mainInsetRef = useMainInsetRef();
const currentPubkey = currentIdentity?.pubkey;
const activeChannelId = activeChannel?.id ?? null;
const relaySelfPubkey = useRelaySelfQuery(activeChannel !== null).data;
const effectiveOpenThreadHeadId =
optimisticOpenThreadHeadId === undefined
? openThreadHeadId
@@ -249,16 +247,11 @@ export function ChannelScreen({
return extraEvents.reduce(mergeMessages, currentMessages);
}, [activeChannel, findEvents, messagesQuery.data, targetMessageEvents]);
const threadReplyEvents = threadRepliesQuery.data ?? EMPTY_RELAY_EVENTS;
const messageEventProfilePubkeys = React.useMemo(() => {
const events = [...resolvedMessages, ...threadReplyEvents];
return [
...new Set([
...collectMessageAuthorPubkeys(events),
...collectMessageMentionPubkeys(events),
...collectReactionActorPubkeys(events),
]),
];
}, [resolvedMessages, threadReplyEvents]);
const messageEventProfilePubkeys = useMessageEventProfilePubkeys(
resolvedMessages,
threadReplyEvents,
relaySelfPubkey,
);
const latestMessageEvent = React.useMemo(
() => resolvedMessages[resolvedMessages.length - 1] ?? null,
[resolvedMessages],
@@ -267,6 +260,7 @@ export function ChannelScreen({
activeChannel,
currentPubkey,
latestMessageEvent,
relaySelfPubkey,
);
const activeDmParticipantPubkeys = React.useMemo(
() =>
@@ -396,6 +390,7 @@ export function ChannelScreen({
channelMembers,
personaLookup,
respondToLookup,
relaySelfPubkey,
),
[
activeChannel,
@@ -404,6 +399,7 @@ export function ChannelScreen({
currentPubkey,
messageProfiles,
personaLookup,
relaySelfPubkey,
respondToLookup,
resolvedMessages,
],
@@ -442,6 +438,7 @@ export function ChannelScreen({
members: channelMembers,
personaLookup,
respondToLookup,
relaySelfPubkey,
});
const {
firstUnreadMessageId,

View File

@@ -0,0 +1,25 @@
import * as React from "react";
import {
collectMessageAuthorPubkeys,
collectMessageMentionPubkeys,
collectReactionActorPubkeys,
} from "@/features/messages/lib/formatTimelineMessages";
import type { RelayEvent } from "@/shared/api/types";
export function useMessageEventProfilePubkeys(
messages: RelayEvent[],
threadReplies: RelayEvent[],
relaySelfPubkey: string | null | undefined,
) {
return React.useMemo(() => {
const events = [...messages, ...threadReplies];
return [
...new Set([
...collectMessageAuthorPubkeys(events, relaySelfPubkey),
...collectMessageMentionPubkeys(events),
...collectReactionActorPubkeys(events, relaySelfPubkey),
]),
];
}, [messages, relaySelfPubkey, threadReplies]);
}

View File

@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getForumPosts, getForumThread } from "@/shared/api/forum";
import { useRelaySelfQuery } from "@/features/moderation/hooks";
import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri";
import type {
Channel,
@@ -19,11 +20,13 @@ export function forumThreadQueryKey(channelId: string, eventId: string) {
export function useForumPostsQuery(channel: Channel | null) {
const channelId = channel?.id ?? "";
const enabled = channel !== null && channel.channelType === "forum";
const relaySelfPubkey = useRelaySelfQuery(enabled).data;
return useQuery<ForumPostsResponse>({
enabled: channel !== null && channel.channelType === "forum",
queryKey: forumPostsQueryKey(channelId),
queryFn: () => getForumPosts(channelId, 50),
enabled,
queryKey: [...forumPostsQueryKey(channelId), relaySelfPubkey ?? null],
queryFn: () => getForumPosts(channelId, 50, undefined, relaySelfPubkey),
staleTime: 15_000,
refetchInterval: 15_000,
});
@@ -33,10 +36,23 @@ export function useForumThreadQuery(
channelId: string | null,
eventId: string | null,
) {
const enabled = channelId !== null && eventId !== null;
const relaySelfPubkey = useRelaySelfQuery(enabled).data;
return useQuery<ForumThreadResponse>({
enabled: channelId !== null && eventId !== null,
queryKey: forumThreadQueryKey(channelId ?? "", eventId ?? ""),
queryFn: () => getForumThread(channelId ?? "", eventId ?? ""),
enabled,
queryKey: [
...forumThreadQueryKey(channelId ?? "", eventId ?? ""),
relaySelfPubkey ?? null,
],
queryFn: () =>
getForumThread(
channelId ?? "",
eventId ?? "",
undefined,
undefined,
relaySelfPubkey,
),
staleTime: 10_000,
refetchInterval: 10_000,
});

View File

@@ -72,9 +72,9 @@ export type InboxReply = {
reactions?: TimelineReaction[];
rootId?: string | null;
/**
* Raw event signer, NOT the tag-attributed display author
* (`authorPubkey`). The config-nudge trust gate authenticates against
* this field so `actor`/`p`-tag spoofing can't enable the card.
* Raw event signer, not a relay-delegated display author (`authorPubkey`).
* The config-nudge trust gate authenticates against this field because only
* the signing agent may enable the card.
*/
signerPubkey?: string;
tags?: string[][];

View File

@@ -317,7 +317,7 @@ test("inbox mappings preserve kind and signerPubkey so the nudge gate can pass",
);
});
test("inbox mappings keep the raw signer distinct from a spoofed actor tag", () => {
test("inbox mappings ignore a spoofed actor tag", () => {
const message = mapThroughInboxPipeline(
makeNudgeEvent({
pubkey: NUDGE_HUMAN_SIGNER,
@@ -328,9 +328,9 @@ test("inbox mappings keep the raw signer distinct from a spoofed actor tag", ()
}),
);
// The attributed display author resolves to the agent (actor tag), but the
// signer stays human — the gate must reject.
assert.equal(message.pubkey?.toLowerCase(), NUDGE_AGENT_SIGNER);
// An untrusted actor tag cannot replace the display author. The signer also
// stays human, so the config-nudge gate rejects the message.
assert.equal(message.pubkey?.toLowerCase(), NUDGE_HUMAN_SIGNER);
assert.equal(message.signerPubkey, NUDGE_HUMAN_SIGNER);
assert.equal(
getConfigNudgeAuthorPubkey(

View File

@@ -54,6 +54,7 @@ import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown";
import { getThreadReference } from "@/features/messages/lib/threading";
import { useActiveDraftCount } from "@/features/messages/ui/DraftsPanel";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { useRelaySelfQuery } from "@/features/moderation/hooks";
import { resolveUserLabel } from "@/features/profile/lib/identity";
import {
countDueReminders,
@@ -124,6 +125,7 @@ export function HomeView({
onOpenContext,
onRefresh,
}: HomeViewProps) {
const relaySelfPubkey = useRelaySelfQuery().data;
const [homeInboxRef, homeInboxWidthPx] = useElementWidth<HTMLDivElement>();
const isNarrowHomeViewport =
homeInboxWidthPx > 0 &&
@@ -471,6 +473,10 @@ export function HomeView({
currentPubkey,
currentUserAvatarUrl,
feedProfiles,
undefined,
undefined,
undefined,
relaySelfPubkey,
);
return timelineMessages.map((message) =>
@@ -485,6 +491,7 @@ export function HomeView({
channelMessages,
currentPubkey,
feedProfiles,
relaySelfPubkey,
selectedChannel,
selectedEventId,
selectedItem,

View File

@@ -187,8 +187,8 @@ export function InboxMessageRow({
)}
// Only pass the author pubkey for agent-authored messages so
// config-nudge cards can authenticate the sender. Uses the
// raw event signer (signerPubkey) — not the tag-attributed
// display author — to prevent actor/p-tag spoofing.
// raw event signer (signerPubkey), not a relay-delegated display
// author, because the agent itself must have signed the card.
configNudgeAuthorPubkey={getConfigNudgeAuthorPubkey(
timelineMessage,
isKnownAgentPubkey,

View File

@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
import {
collectMessageAuthorPubkeys,
@@ -23,6 +24,8 @@ const PUBKEY_A =
"1111111111111111111111111111111111111111111111111111111111111111";
const PUBKEY_B =
"2222222222222222222222222222222222222222222222222222222222222222";
const RELAY_SECRET = new Uint8Array(32).fill(3);
const RELAY_PUBKEY = getPublicKey(RELAY_SECRET);
const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f";
function streamMessage(overrides = {}) {
@@ -170,7 +173,7 @@ test("non-deletion event kinds do NOT hide the target message", () => {
assert.equal(out.length, 1, "the kind:9 message should still be visible");
});
test("collectMessageAuthorPubkeys includes the signer and attributed actor", () => {
test("user-signed actor tag does not affect timeline identity or profile loading", () => {
const events = [
streamMessage({
tags: [
@@ -180,10 +183,97 @@ test("collectMessageAuthorPubkeys includes the signer and attributed actor", ()
}),
];
assert.deepEqual(
new Set(collectMessageAuthorPubkeys(events)),
new Set([PUBKEY_A, PUBKEY_B]),
const profiles = {
[PUBKEY_A]: {
displayName: "Real signer",
avatarUrl: "https://example.test/signer.png",
nip05Handle: null,
ownerPubkey: null,
},
[PUBKEY_B]: {
displayName: "Spoofed admin",
avatarUrl: "https://example.test/admin.png",
nip05Handle: null,
ownerPubkey: null,
},
};
const members = [
{
pubkey: PUBKEY_A,
role: "member",
isAgent: false,
joinedAt: "2026-01-01T00:00:00Z",
displayName: "Real signer",
},
{
pubkey: PUBKEY_B,
role: "owner",
isAgent: false,
joinedAt: "2026-01-01T00:00:00Z",
displayName: "Spoofed admin",
},
];
assert.deepEqual(collectMessageAuthorPubkeys(events, RELAY_PUBKEY), [
PUBKEY_A,
]);
const [message] = formatTimelineMessages(
events,
null,
undefined,
null,
profiles,
members,
undefined,
undefined,
RELAY_PUBKEY,
);
assert.equal(message.pubkey, PUBKEY_A);
assert.equal(message.signerPubkey, PUBKEY_A);
assert.equal(message.author, "Real signer");
assert.equal(message.avatarUrl, "https://example.test/signer.png");
assert.equal(message.role, "member");
});
test("relay-signed actor tag resolves the delegated timeline author", () => {
const event = finalizeEvent(
{
kind: 9,
created_at: 1_700_000_000,
content: "hello world",
tags: [
["h", CHANNEL_ID],
["actor", PUBKEY_B],
],
},
RELAY_SECRET,
);
const profiles = {
[PUBKEY_B]: {
displayName: "Delegated user",
avatarUrl: "https://example.test/delegated.png",
nip05Handle: null,
ownerPubkey: null,
},
};
const [message] = formatTimelineMessages(
[event],
null,
undefined,
null,
profiles,
undefined,
undefined,
undefined,
RELAY_PUBKEY,
);
assert.equal(message.pubkey, PUBKEY_B);
assert.equal(message.signerPubkey, RELAY_PUBKEY);
assert.equal(message.author, "Delegated user");
});
test("collectReactionActorPubkeys returns active kind:7 actors only", () => {
@@ -234,7 +324,10 @@ test("collectReactionActorPubkeys returns active kind:7 actors only", () => {
}),
];
assert.deepEqual(collectReactionActorPubkeys(events), [PUBKEY_B]);
assert.deepEqual(collectReactionActorPubkeys(events, RELAY_PUBKEY), [
PUBKEY_B,
PUBKEY_A,
]);
});
test("huddle start renders as a timeline row", () => {

View File

@@ -135,11 +135,12 @@ function formatMessageAuthor(
channel: Channel | null,
currentPubkey: string | undefined,
profiles: UserProfileLookup | undefined,
relaySelfPubkey: string | null | undefined,
) {
const authorPubkey = resolveEventAuthorPubkey({
pubkey: event.pubkey,
tags: event.tags,
event,
preferActorTag: true,
relaySelfPubkey,
requireChannelTagForPTags: true,
});
const fallbackName =
@@ -190,6 +191,8 @@ export function formatTimelineMessages(
personaLookup?: Map<string, string>,
/** Map from lowercase pubkey → respond-to mode for bot messages. */
respondToLookup?: Map<string, RespondToMode>,
/** Active relay identity from NIP-11 `self`; absent or malformed fails closed to the signer. */
relaySelfPubkey?: string | null,
): TimelineMessage[] {
const currentPubkeyLower = currentPubkey?.toLowerCase();
const roleByPubkey = new Map<string, string>();
@@ -271,9 +274,9 @@ export function formatTimelineMessages(
}
const actorPubkey = resolveEventAuthorPubkey({
pubkey: event.pubkey,
tags: event.tags,
event,
preferActorTag: true,
relaySelfPubkey,
requireChannelTagForPTags: true,
}).toLowerCase();
const emoji = event.content.trim() || "+";
@@ -358,12 +361,18 @@ export function formatTimelineMessages(
}
const authorPubkey = resolveEventAuthorPubkey({
pubkey: event.pubkey,
tags: event.tags,
event,
preferActorTag: true,
relaySelfPubkey,
requireChannelTagForPTags: true,
});
const author = formatMessageAuthor(event, channel, currentPubkey, profiles);
const author = formatMessageAuthor(
event,
channel,
currentPubkey,
profiles,
relaySelfPubkey,
);
authorPubkeyByEventId.set(event.id, authorPubkey);
authorLabelByEventId.set(event.id, author);
@@ -406,9 +415,9 @@ export function formatTimelineMessages(
const authorPubkey =
authorPubkeyByEventId.get(event.id) ??
resolveEventAuthorPubkey({
pubkey: event.pubkey,
tags: event.tags,
event,
preferActorTag: true,
relaySelfPubkey,
requireChannelTagForPTags: true,
});
const thread = getThreadReference(event.tags);
@@ -487,7 +496,10 @@ function extractSystemMessagePubkeys(event: RelayEvent): string[] {
}
}
export function collectReactionActorPubkeys(events: RelayEvent[]) {
export function collectReactionActorPubkeys(
events: RelayEvent[],
relaySelfPubkey?: string | null,
) {
const deletedEventIds = new Set<string>();
for (const event of events) {
if (
@@ -511,9 +523,9 @@ export function collectReactionActorPubkeys(events: RelayEvent[]) {
}
pubkeys.add(
resolveEventAuthorPubkey({
pubkey: event.pubkey,
tags: event.tags,
event,
preferActorTag: true,
relaySelfPubkey,
requireChannelTagForPTags: true,
}).toLowerCase(),
);
@@ -521,7 +533,10 @@ export function collectReactionActorPubkeys(events: RelayEvent[]) {
return [...pubkeys];
}
export function collectMessageAuthorPubkeys(events: RelayEvent[]) {
export function collectMessageAuthorPubkeys(
events: RelayEvent[],
relaySelfPubkey?: string | null,
) {
const pubkeys = new Set<string>();
for (const event of events) {
@@ -537,9 +552,9 @@ export function collectMessageAuthorPubkeys(events: RelayEvent[]) {
pubkeys.add(event.pubkey.toLowerCase());
pubkeys.add(
resolveEventAuthorPubkey({
pubkey: event.pubkey,
tags: event.tags,
event,
preferActorTag: true,
relaySelfPubkey,
requireChannelTagForPTags: true,
}).toLowerCase(),
);

View File

@@ -19,9 +19,9 @@ export type TimelineMessage = {
pubkey?: string;
/**
* Raw signer pubkey (`event.pubkey`), normalized to lowercase hex.
* Distinct from `pubkey`, which is the resolved display-author and may be
* overridden by `actor` or `p` tags. Use this field — not `pubkey` — for
* security-sensitive checks such as authenticating config-nudge cards.
* Distinct from `pubkey`, which may be a delegated author on an event signed
* by the active relay. Use this field for checks that require the process or
* user that cryptographically signed the event.
*/
signerPubkey?: string;
author: string;

View File

@@ -343,8 +343,8 @@ export const MessageRow = React.memo(
)}
// Only pass the author pubkey for agent-authored messages so
// config-nudge cards can authenticate the sender. Uses the
// raw event signer (signerPubkey) — not the tag-attributed
// display author — to prevent actor/p-tag spoofing.
// raw event signer (signerPubkey), not a relay-delegated display
// author, because the agent itself must have signed the card.
configNudgeAuthorPubkey={getConfigNudgeAuthorPubkey(
message,
isKnownAgentPubkey,

View File

@@ -1,12 +1,10 @@
/**
* Integration-seam tests for getConfigNudgeAuthorPubkey.
*
* These tests exercise the exact field-selection seam that was broken:
* formatTimelineMessages retains signerPubkey = raw event.pubkey while
* resolving message.pubkey to the tag-attributed author. A test that
* passes the author pubkey by hand (as the previous regression did) would
* not catch a regression that reverts MessageRow to using message.pubkey
* (the spoofable field) instead of signerPubkey.
* These tests exercise the exact field-selection seam used by MessageRow.
* Ordinary user events cannot delegate authorship through tags, while a
* relay-signed event may still have a display author distinct from its signer.
* Config-nudge authentication must always use the signer.
*
* By constructing a real TimelineMessage via formatTimelineMessages and
* passing it to getConfigNudgeAuthorPubkey — the same helper MessageRow
@@ -14,6 +12,7 @@
*/
import assert from "node:assert/strict";
import test from "node:test";
import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
import { formatTimelineMessages } from "../lib/formatTimelineMessages.ts";
import { getConfigNudgeAuthorPubkey } from "./configNudgeAuthPubkey.ts";
@@ -26,6 +25,8 @@ const HUMAN_SIGNER =
// The attributed agent pubkey (appears in actor/p tag).
const AGENT_PUBKEY =
"2222222222222222222222222222222222222222222222222222222222222222";
const RELAY_SECRET = new Uint8Array(32).fill(4);
const RELAY_SIGNER = getPublicKey(RELAY_SECRET);
// MessageRow passes a predicate combining the community known-agent set with
// per-pubkey profile `isAgent` checks; the set-membership form is the minimal
@@ -46,17 +47,37 @@ function makeEvent(overrides = {}) {
};
}
function format(event) {
return formatTimelineMessages([event], null, undefined, null);
function makeRelayEvent(tags) {
return finalizeEvent(
{
kind: 9,
created_at: 1_700_000_000,
content: "**Fizz** needs configuration.\n\n```buzz:config-nudge\n{}\n```",
tags,
},
RELAY_SECRET,
);
}
function format(event, relaySelfPubkey) {
return formatTimelineMessages(
[event],
null,
undefined,
null,
undefined,
undefined,
undefined,
undefined,
relaySelfPubkey,
);
}
// ── Spoof-regression test ─────────────────────────────────────────────────────
//
// A human-signed kind:9 event carries an `actor` tag attributing it to the
// agent. formatTimelineMessages resolves message.pubkey = agent (via the
// actor tag) but retains signerPubkey = human (from raw event.pubkey).
// getConfigNudgeAuthorPubkey MUST return undefined — the human signer is
// not in the known-agent set, so the card must not render.
// A human-signed kind:9 event carries an `actor` tag naming an agent. The
// timeline must ignore the untrusted attribution, and the config-nudge gate
// must continue authenticating the human signer.
test("signerIsHuman_actorTagAttributedToAgent_returnsUndefined", () => {
const event = makeEvent({
@@ -69,17 +90,15 @@ test("signerIsHuman_actorTagAttributedToAgent_returnsUndefined", () => {
const [msg] = format(event);
// Verify the seam: pubkey resolves to agent (tag-attributed), signerPubkey
// is human (raw signer). If these are equal the test loses its meaning.
assert.equal(
msg.pubkey?.toLowerCase(),
AGENT_PUBKEY,
"attributed pubkey must be the agent (actor tag wins in resolveEventAuthorPubkey)",
HUMAN_SIGNER,
"an untrusted actor tag must not replace the visible author",
);
assert.notEqual(
assert.equal(
msg.signerPubkey,
AGENT_PUBKEY,
"signerPubkey must NOT be the agent (event was signed by human)",
HUMAN_SIGNER,
"signerPubkey must remain the raw event signer",
);
// The guard must reject: signer is human, not in AGENT_PUBKEYS.
@@ -90,6 +109,23 @@ test("signerIsHuman_actorTagAttributedToAgent_returnsUndefined", () => {
);
});
test("relayDelegatesToAgent_relaySigner_returnsUndefined", () => {
const event = makeRelayEvent([
["h", CHANNEL_ID],
["actor", AGENT_PUBKEY],
]);
const [msg] = format(event, RELAY_SIGNER);
assert.equal(msg.pubkey, AGENT_PUBKEY);
assert.equal(msg.signerPubkey, RELAY_SIGNER);
assert.equal(
getConfigNudgeAuthorPubkey(msg, isKnownAgentPubkey),
undefined,
"relay delegation must not be treated as an agent signature",
);
});
// ── Positive case ─────────────────────────────────────────────────────────────
//
// A genuine kind:9 signed by the agent itself: getConfigNudgeAuthorPubkey

View File

@@ -10,13 +10,13 @@ import type { TimelineMessage } from "@/features/messages/types";
* wire format.
* 2. `message.signerPubkey` is set and passes `isKnownAgentPubkey` —
* authenticates against the raw event signer (NOT `message.pubkey`,
* which is the tag-attributed display author and can be spoofed via
* `actor`/`p` tags). The caller's predicate combines the community-wide
* known-agent baseline (`useKnownAgentPubkeys`) with any surface-local
* signals such as the signer profile's `isAgent` flag.
* which may be a relay-delegated display author). The caller's predicate
* combines the community-wide known-agent baseline
* (`useKnownAgentPubkeys`) with any surface-local signals such as the
* signer profile's `isAgent` flag.
*
* Extracting this predicate as a pure helper lets tests exercise the exact
* signer-vs-attributed-author distinction with a real `TimelineMessage` from
* signer-vs-delegated-author distinction with a real `TimelineMessage` from
* `formatTimelineMessages`, without a full React render harness.
*/
export function getConfigNudgeAuthorPubkey(

View File

@@ -69,6 +69,7 @@ export function useChannelTyping(
channel: Channel | null,
currentPubkey?: string,
latestMessageEvent?: RelayEvent | null,
relaySelfPubkey?: string | null,
) {
const channelId = channel?.id ?? null;
const channelType = channel?.channelType ?? null;
@@ -150,9 +151,9 @@ export function useChannelTyping(
}
const authorPubkey = resolveEventAuthorPubkey({
pubkey: latestMessageEvent.pubkey,
tags: latestMessageEvent.tags,
event: latestMessageEvent,
preferActorTag: true,
relaySelfPubkey,
requireChannelTagForPTags: true,
}).toLowerCase();
const threadHeadId = getTypingScopeId(latestMessageEvent);
@@ -173,7 +174,7 @@ export function useChannelTyping(
delete updated[typingKey];
return updated;
});
}, [channelId, latestMessageEvent]);
}, [channelId, latestMessageEvent, relaySelfPubkey]);
useEffect(() => {
if (!channelId || channelType === "forum") {

View File

@@ -22,6 +22,7 @@ export function useIndependentThreadPanel(args: {
members: ChannelMember[] | undefined;
personaLookup: Map<string, string>;
respondToLookup: Map<string, RespondToMode>;
relaySelfPubkey: string | null | undefined;
}) {
// Depend on the individual fields, NOT the `args` object — callers pass a
// fresh object literal every render, so `[args]` never memoizes and the
@@ -46,6 +47,7 @@ export function useIndependentThreadPanel(args: {
args.members,
args.personaLookup,
args.respondToLookup,
args.relaySelfPubkey,
),
[
args.channelEvents,
@@ -60,6 +62,7 @@ export function useIndependentThreadPanel(args: {
args.members,
args.personaLookup,
args.respondToLookup,
args.relaySelfPubkey,
],
);
}

View File

@@ -28,9 +28,9 @@ export const relaySelfQueryKey = ["relaySelf"] as const;
/**
* The active relay's NIP-11 `self` pubkey (hex), or `null` when it advertises
* none / is unreachable. Used to recognize a moderation DM. Community-scoped
* and effectively static for a session, so it is cached indefinitely; a `null`
* result is a valid answer (fail open), not an error to retry into.
* none. Used to recognize relay-signed state and moderation DMs. Community-
* scoped and effectively static for a session, so it is cached indefinitely;
* a `null` result is a valid answer, while request failures remain query errors.
*/
export function useRelaySelfQuery(enabled = true) {
return useQuery({

View File

@@ -2,10 +2,9 @@ import { invokeTauri } from "@/shared/api/tauri";
/**
* Read the active relay's NIP-11 `self` pubkey (its own signing key, hex), or
* `null` when the relay advertises none / is unreachable / serves a malformed
* document. Used by the moderation UI to recognize a DM with the relay identity
* (a moderation DM). Fails open by contract: a `null` result must be treated as
* "not the relay", never as an error.
* `null` when the relay advertises none or an invalid key. Network and malformed
* document failures reject the request. Callers that use this value to trust
* relay-signed state must treat both `null` and errors as untrusted.
*/
export function getRelaySelf(): Promise<string | null> {
return invokeTauri<string | null>("get_relay_self");

View File

@@ -36,8 +36,8 @@ const TIMEOUT_PRESETS: { label: string; seconds: number }[] = [
*
* Renders nothing unless the viewer is a relay owner/admin, the message has a
* real signer, and that signer is not the viewer. Actions target
* `signerPubkey` — the raw signer, never the display-author (which `p`/`actor`
* tags can override) — per the security note on TimelineMessage.
* `signerPubkey` — the raw signer, never a relay-delegated display author — per
* the security note on TimelineMessage.
*/
export function MessageModerationMenuItems({
channelId,
@@ -51,9 +51,8 @@ export function MessageModerationMenuItems({
const canModerate = relayRole === "owner" || relayRole === "admin";
const identityQuery = useIdentityQuery();
// Fail closed: moderate only the raw signer, never the display `pubkey`
// (which `p`/`actor` tags can override — see the security note above). A
// message without a signer is not moderatable here; render nothing.
// Moderate the raw signer, never a relay-delegated display author. A message
// without a signer is not moderatable here; render nothing.
const targetPubkey = message.signerPubkey ?? null;
const isSelf =
targetPubkey != null &&

View File

@@ -24,6 +24,7 @@ type RawForumPost = {
created_at: number;
channel_id: string;
tags: string[][];
sig: string;
thread_summary: RawThreadSummary | null;
reactions: unknown;
};
@@ -41,6 +42,7 @@ type RawThreadReply = {
created_at: number;
channel_id: string;
tags: string[][];
sig: string;
parent_event_id: string | null;
root_event_id: string | null;
depth: number;
@@ -55,12 +57,23 @@ type RawForumThreadResponse = {
next_cursor: string | null;
};
function fromRawForumPost(post: RawForumPost): ForumPost {
function fromRawForumPost(
post: RawForumPost,
relaySelfPubkey?: string | null,
): ForumPost {
return {
eventId: post.event_id,
pubkey: resolveEventAuthorPubkey({
pubkey: post.pubkey,
tags: post.tags,
event: {
id: post.event_id,
pubkey: post.pubkey,
created_at: post.created_at,
kind: post.kind,
tags: post.tags,
content: post.content,
sig: post.sig,
},
relaySelfPubkey,
}),
content: post.content,
kind: post.kind,
@@ -78,12 +91,23 @@ function fromRawForumPost(post: RawForumPost): ForumPost {
};
}
function fromRawThreadReply(reply: RawThreadReply): ThreadReply {
function fromRawThreadReply(
reply: RawThreadReply,
relaySelfPubkey?: string | null,
): ThreadReply {
return {
eventId: reply.event_id,
pubkey: resolveEventAuthorPubkey({
pubkey: reply.pubkey,
tags: reply.tags,
event: {
id: reply.event_id,
pubkey: reply.pubkey,
created_at: reply.created_at,
kind: reply.kind,
tags: reply.tags,
content: reply.content,
sig: reply.sig,
},
relaySelfPubkey,
}),
content: reply.content,
kind: reply.kind,
@@ -100,6 +124,7 @@ export async function getForumPosts(
channelId: string,
limit?: number,
before?: number,
relaySelfPubkey?: string | null,
): Promise<ForumPostsResponse> {
const response = await invokeTauri<RawForumPostsResponse>("get_forum_posts", {
channelId,
@@ -110,7 +135,7 @@ export async function getForumPosts(
return {
posts: response.messages
.filter((m) => m.kind === KIND_FORUM_POST)
.map(fromRawForumPost),
.map((post) => fromRawForumPost(post, relaySelfPubkey)),
nextCursor: response.next_cursor,
};
}
@@ -120,6 +145,7 @@ export async function getForumThread(
eventId: string,
limit?: number,
cursor?: string,
relaySelfPubkey?: string | null,
): Promise<ForumThreadResponse> {
const response = await invokeTauri<RawForumThreadResponse>(
"get_forum_thread",
@@ -132,8 +158,10 @@ export async function getForumThread(
);
return {
post: fromRawForumPost(response.root),
replies: response.replies.map(fromRawThreadReply),
post: fromRawForumPost(response.root, relaySelfPubkey),
replies: response.replies.map((reply) =>
fromRawThreadReply(reply, relaySelfPubkey),
),
totalReplies: response.total_replies,
nextCursor: response.next_cursor,
};

View File

@@ -0,0 +1,128 @@
import assert from "node:assert/strict";
import test from "node:test";
import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
import { resolveEventAuthorPubkey } from "./authors.ts";
const SIGNER_SECRET = new Uint8Array(32).fill(1);
const RELAY_SECRET = new Uint8Array(32).fill(2);
const SIGNER = getPublicKey(SIGNER_SECRET);
const RELAY = getPublicKey(RELAY_SECRET);
const ATTRIBUTED_USER = "33".repeat(32);
const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f";
function resolve({
signer = "user",
tags,
relaySelfPubkey = RELAY,
preferActorTag = true,
tamperAfterSigning = false,
}) {
const event = finalizeEvent(
{
kind: 9,
created_at: 1_700_000_000,
content: "hello",
tags,
},
signer === "relay" ? RELAY_SECRET : SIGNER_SECRET,
);
const eventToResolve = tamperAfterSigning
? { ...JSON.parse(JSON.stringify(event)), content: "tampered" }
: event;
return resolveEventAuthorPubkey({
event: eventToResolve,
preferActorTag,
relaySelfPubkey,
requireChannelTagForPTags: true,
});
}
test("user-signed actor tag cannot replace the visible author", () => {
assert.equal(
resolve({
tags: [
["h", CHANNEL_ID],
["actor", ATTRIBUTED_USER],
],
}),
SIGNER,
);
});
test("user-signed first p tag cannot replace the visible author", () => {
assert.equal(
resolve({
tags: [
["p", ATTRIBUTED_USER],
["h", CHANNEL_ID],
],
}),
SIGNER,
);
});
test("relay-signed actor tag resolves to the delegated author", () => {
assert.equal(
resolve({
signer: "relay",
tags: [
["h", CHANNEL_ID],
["actor", ATTRIBUTED_USER],
],
}),
ATTRIBUTED_USER,
);
});
test("relay-signed author p tag resolves to the delegated author", () => {
assert.equal(
resolve({
signer: "relay",
preferActorTag: false,
tags: [
["p", ATTRIBUTED_USER],
["h", CHANNEL_ID],
],
}),
ATTRIBUTED_USER,
);
});
test("missing or malformed relay identity fails closed to the signer", () => {
const tags = [
["h", CHANNEL_ID],
["actor", ATTRIBUTED_USER],
];
assert.equal(resolve({ tags, relaySelfPubkey: null }), SIGNER);
assert.equal(resolve({ tags, relaySelfPubkey: "not-a-pubkey" }), SIGNER);
});
test("malformed relay-signed attribution fails closed to the signer", () => {
assert.equal(
resolve({
signer: "relay",
tags: [
["h", CHANNEL_ID],
["actor", "not-a-pubkey"],
],
}),
RELAY,
);
});
test("invalid relay event signature fails closed to the signer", () => {
assert.equal(
resolve({
signer: "relay",
tags: [
["h", CHANNEL_ID],
["actor", ATTRIBUTED_USER],
],
tamperAfterSigning: true,
}),
RELAY,
);
});

View File

@@ -1,7 +1,17 @@
import { normalizePubkey } from "@/shared/lib/pubkey";
import { verifyEvent } from "nostr-tools/pure";
const PUBKEY_HEX_RE = /^[0-9a-f]{64}$/i;
function normalizeValidPubkey(pubkey: string | null | undefined) {
if (!pubkey) {
return null;
}
const normalized = normalizePubkey(pubkey);
return PUBKEY_HEX_RE.test(normalized) ? normalized : null;
}
function getTaggedPubkey(
tags: string[][],
tagName: string,
@@ -21,34 +31,67 @@ function getTaggedPubkey(
return null;
}
export function resolveEventAuthorPubkey(input: {
type AuthorResolutionEvent = {
id: string;
pubkey: string;
created_at: number;
kind: number;
tags: string[][];
content: string;
sig: string;
};
function hasValidSignature(event: AuthorResolutionEvent) {
try {
return verifyEvent(event);
} catch {
return false;
}
}
export function resolveEventAuthorPubkey(input: {
event: AuthorResolutionEvent;
preferActorTag?: boolean;
relaySelfPubkey?: string | null;
requireChannelTagForPTags?: boolean;
}) {
const {
event,
preferActorTag = false,
pubkey,
relaySelfPubkey,
requireChannelTagForPTags = false,
tags,
} = input;
const signerPubkey = normalizePubkey(event.pubkey);
const normalizedRelaySelf = normalizeValidPubkey(relaySelfPubkey);
// `actor` and author-attributing `p` tags are delegated authorship claims.
// The relay creates these for workflow-generated and legacy relay-signed
// attributed events, so they are only authoritative when the event is signed
// by the active relay advertised in NIP-11. Missing or malformed relay
// identity data must leave the signer as the visible author.
if (!normalizedRelaySelf || signerPubkey !== normalizedRelaySelf) {
return signerPubkey;
}
let attributedPubkey: string | null = null;
if (preferActorTag) {
const actorPubkey = getTaggedPubkey(tags, "actor");
if (actorPubkey) {
return actorPubkey;
attributedPubkey = getTaggedPubkey(event.tags, "actor");
}
if (!attributedPubkey) {
const canUseAttributedPTag =
!requireChannelTagForPTags || event.tags.some((tag) => tag[0] === "h");
if (canUseAttributedPTag) {
attributedPubkey = getTaggedPubkey(event.tags, "p", {
firstTagOnly: true,
});
}
}
const canUseAttributedPTag =
!requireChannelTagForPTags || tags.some((tag) => tag[0] === "h");
if (canUseAttributedPTag) {
const attributedPubkey = getTaggedPubkey(tags, "p", { firstTagOnly: true });
if (attributedPubkey) {
return attributedPubkey;
}
if (!attributedPubkey || !hasValidSignature(event)) {
return signerPubkey;
}
return normalizePubkey(pubkey);
return attributedPubkey;
}

View File

@@ -1334,16 +1334,13 @@ test("share access controls include the selected memories", async ({
);
});
test("people sharing waits for relay identity and excludes the moderation recipient", async ({
page,
}) => {
test("people sharing excludes the moderation recipient", async ({ page }) => {
await openSafetyShareDialog(page, {
relaySelf: TEST_IDENTITIES.charlie.pubkey,
relaySelfDelayMs: 800,
});
const search = page.getByTestId("persona-share-recipient-search");
await expect(search).toBeDisabled();
await expect(search).toBeEnabled({ timeout: 5_000 });
await search.fill("charlie");
await expect(