mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
[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:
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
70
desktop/src-tauri/src/commands/messages/forum.rs
Normal file
70
desktop/src-tauri/src/commands/messages/forum.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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[][];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
128
desktop/src/shared/lib/authors.test.mjs
Normal file
128
desktop/src/shared/lib/authors.test.mjs
Normal 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,
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user