diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index e04c0a0..61abcd1 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -122,6 +122,28 @@ Owns: - Testnet-only assumptions must be explicit in docs and code comments. - Any privileged operation must validate input shape and origin intent. +## 9a) Signal & reply authorship proof (SEP-53) + +Community signals and replies now carry a Verifiable Ed25519 proof of wallet +ownership instead of a mock signature: + +- **Client** (`components/signal-compose.tsx`, `app/signals/[id]/signal-detail-client.tsx`) + signs a canonical payload `{ title, body, timestamp }` via the selected + wallet's SEP-53 `signMessage` (`lib/viewer-signature.ts:signSignalPayload`). +- **Server** (`app/api/signals/route.ts`, `app/api/signals/[id]/replies/route.ts`) + reconstructs the same payload and verifies it with + `Keypair.fromPublicKey(author).verify(prefix + message, signature)` + (`lib/viewer-signature.ts:verifySignalSignature`). Missing, malformed, or + forged signatures (signature claiming another wallet) are rejected with + `400`. +- **Badge**: verified authorship is persisted as `signature_verified` on the + `signals` / `signal_replies` SQLite rows and surfaced in the UI, so verified + signals are visually distinguished from legacy posts. + +All signing stays client-side; the server never holds user keys. Signed string +is a fixed-size digest of the canonical payload, keeping it under wallet +`sign_message` size limits. + ## 10) Feature flags (rolling delivery) | Flag | Env | Purpose | Default | Rollback | diff --git a/app/api/signals/[id]/replies/route.ts b/app/api/signals/[id]/replies/route.ts index bf45704..20539fa 100644 --- a/app/api/signals/[id]/replies/route.ts +++ b/app/api/signals/[id]/replies/route.ts @@ -4,6 +4,7 @@ import { getSignal, createReply, AttributionInReplySchema, recordReplyAttributio import { createNotification } from "@/lib/notification-store" import { dispatchPushNotification, extractMentionedWallets, isPhase92Enabled } from "@/lib/push-notifications" import { createApiRequestContext } from "@/lib/api-observability" +import { verifySignalSignature } from "@/lib/viewer-signature" import { isFeatureEnabled } from "@/lib/feature-flags" import { z } from "zod" @@ -31,6 +32,7 @@ type ReplyBody = { body?: unknown wallet?: unknown signature?: unknown + timestamp?: unknown attribution?: unknown contributors?: unknown } @@ -81,6 +83,25 @@ export async function POST( { status: 400, event: "signals.reply.validation_failed", metadata: { reason: "body_length" } }, ) } + if (typeof body.timestamp !== "number" || !Number.isFinite(body.timestamp)) { + return api.json( + { error: "Invalid signature timestamp" }, + { status: 400, event: "signals.reply.validation_failed", metadata: { reason: "timestamp" } }, + ) + } + + const walletStr = body.wallet + const signatureVerified = await verifySignalSignature( + walletStr, + { title: "", body: (body.body as string).trim(), timestamp: body.timestamp as number }, + body.signature as string, + ) + if (!signatureVerified) { + return api.json( + { error: "Invalid signature: reply not signed by this wallet" }, + { status: 400, event: "signals.reply.invalid_signature" }, + ) + } // phase-156 (Module #56): reject posts from wallets on the governed deny-list. // No-op when the flag is off. Wrapped so a store read failure never 500s the @@ -144,7 +165,6 @@ export async function POST( return api.json({ error: "Signal not found" }, { status: 404, event: "signals.reply.signal_missing", metadata: { signal_id: id } }) } - const walletStr = body.wallet const res = await fetch( `${request.nextUrl.origin}/api/artist-profile?walletAddress=${encodeURIComponent(walletStr)}`, { headers: { "x-correlation-id": api.correlationId } }, @@ -167,6 +187,7 @@ export async function POST( body: (body.body as string).trim(), upvotes: [], signature: body.signature as string, + signature_verified: signatureVerified, }) // phase-116: record contributor attribution (flag-gated, best-effort) diff --git a/app/api/signals/route.ts b/app/api/signals/route.ts index d178990..9cc3df7 100644 --- a/app/api/signals/route.ts +++ b/app/api/signals/route.ts @@ -5,6 +5,7 @@ import { createSignal, getSignalChannelStats, } from "@/lib/signal-store" +import { verifySignalSignature } from "@/lib/viewer-signature" import type { Signal } from "@/lib/signal-store" import { getAllWorldCollections } from "@/lib/narrative-world-store" import { checkAndUnlock } from "@/lib/achievement-store" @@ -48,6 +49,7 @@ type CreateSignalBody = { channel?: unknown wallet?: unknown signature?: unknown + timestamp?: unknown nft_token_id?: unknown nft_collection_id?: unknown nft_name?: unknown @@ -72,6 +74,9 @@ export async function POST(request: NextRequest) { if (typeof body.signature !== "string" || body.signature.length === 0) { return NextResponse.json({ error: "Signature required" }, { status: 400 }) } + if (typeof body.timestamp !== "number" || !Number.isFinite(body.timestamp)) { + return NextResponse.json({ error: "Invalid signature timestamp" }, { status: 400 }) + } if (typeof body.title !== "string" || body.title.trim().length === 0) { return NextResponse.json({ error: "Title required" }, { status: 400 }) } @@ -88,6 +93,24 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Channel required" }, { status: 400 }) } + const walletStr = body.wallet + const proofPayload = { + title: body.title.trim(), + body: (body.body as string).trim(), + timestamp: body.timestamp as number, + } + const signatureVerified = await verifySignalSignature( + walletStr, + proofPayload, + body.signature as string, + ) + if (!signatureVerified) { + return NextResponse.json( + { error: "Invalid signature: payload not signed by this wallet" }, + { status: 400 }, + ) + } + let poll: Signal["poll"] if (body.type === "poll") { if (!isFeatureEnabled("phase-90")) { @@ -131,7 +154,6 @@ export async function POST(request: NextRequest) { } } - const walletStr = body.wallet const res = await fetch( `${request.nextUrl.origin}/api/artist-profile?walletAddress=${encodeURIComponent(walletStr)}`, ).catch(() => null) @@ -151,6 +173,7 @@ export async function POST(request: NextRequest) { body: (body.body as string).trim(), upvotes: [], signature: body.signature as string, + signature_verified: signatureVerified, type: body.type === "poll" ? "poll" : "post", ...(poll ? { poll } : {}), ...(scheduledFor ? { scheduled_for: scheduledFor } : {}), diff --git a/app/signals/[id]/page.tsx b/app/signals/[id]/page.tsx index bf72acf..9fe8d61 100644 --- a/app/signals/[id]/page.tsx +++ b/app/signals/[id]/page.tsx @@ -105,9 +105,13 @@ export default async function SignalDetailPage({ params }: Props) { - ✓ WALLET + {signal.signature_verified ? "✓ VERIFIED" : "✓ WALLET"} {signal.channel.toUpperCase()} diff --git a/app/signals/[id]/signal-detail-client.tsx b/app/signals/[id]/signal-detail-client.tsx index 631d97e..4bde7c4 100644 --- a/app/signals/[id]/signal-detail-client.tsx +++ b/app/signals/[id]/signal-detail-client.tsx @@ -4,6 +4,7 @@ import { useState } from "react" import { useWallet } from "@/components/wallet-provider" import { useLang } from "@/components/lang-context" import { WalletAvatar } from "@/components/wallet-avatar" +import { signSignalPayload } from "@/lib/viewer-signature" import type { SignalReply } from "@/lib/signal-store" const copy = { @@ -15,6 +16,8 @@ const copy = { ctaBusy: "[ SENDING… ]", noWallet: "[ CONNECT_WALLET_TO_REPLY ]", walletBadge: "✓ WALLET", + verifiedBadge: "✓ VERIFIED", + legacyBadge: "LEGACY", }, es: { replies: "RESPUESTAS", @@ -24,6 +27,8 @@ const copy = { ctaBusy: "[ ENVIANDO… ]", noWallet: "[ CONECTAR_WALLET_PARA_RESPONDER ]", walletBadge: "✓ WALLET", + verifiedBadge: "✓ VERIFICADO", + legacyBadge: "LEGADO", }, } @@ -60,14 +65,20 @@ export function SignalDetailClient({ signalId, initialReplies }: Props) { setError(null) setBusy(true) try { + const timestamp = Date.now() + const replyBodyTrimmed = replyBody.trim() + const signature = await signSignalPayload( + { title: "", body: replyBodyTrimmed, timestamp }, + address, + ) const res = await fetch(`/api/signals/${signalId}/replies`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - body: replyBody.trim(), + body: replyBodyTrimmed, wallet: address, - // TODO: replace provisional signature with Freighter signMessage when available - signature: address, + signature, + timestamp, }), }) const data = (await res.json().catch(() => ({}))) as { reply?: SignalReply; error?: string } @@ -113,9 +124,13 @@ export function SignalDetailClient({ signalId, initialReplies }: Props) { - {t.walletBadge} + {r.signature_verified ? t.verifiedBadge : t.walletBadge} {timeAgo(r.created_at)} diff --git a/app/signals/page.tsx b/app/signals/page.tsx index 9d6253a..2589424 100644 --- a/app/signals/page.tsx +++ b/app/signals/page.tsx @@ -31,6 +31,7 @@ const copy = { replies: "replies", upvotes: "upvotes", walletBadge: "✓ WALLET", + verifiedBadge: "✓ VERIFIED", onChain: "✓ ON-CHAIN", unverified: "UNVERIFIED", collector: "✓ COLLECTOR", @@ -59,6 +60,7 @@ const copy = { replies: "respuestas", upvotes: "votos", walletBadge: "✓ WALLET", + verifiedBadge: "✓ VERIFICADO", onChain: "✓ ON-CHAIN", unverified: "NO_VERIFICADO", collector: "✓ COLECCIONISTA", @@ -194,9 +196,13 @@ function PostCard({ - {t.walletBadge} + {signal.signature_verified ? t.verifiedBadge : t.walletBadge} {authorProfile.isCollector && ( (null) + const containerRef = useRef(null) + const [isVisible, setIsVisible] = useState(true) + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false) useEffect(() => { - const canvas = canvasRef.current - if (!canvas) return - - const ctx = canvas.getContext("2d") - if (!ctx) return - - let animationId: number - let frame = 0 - - const resize = () => { - canvas.width = canvas.offsetWidth / 2 - canvas.height = canvas.offsetHeight / 2 - } - - const generateNoise = () => { - const imageData = ctx.createImageData(canvas.width, canvas.height) - const data = imageData.data - - for (let i = 0; i < data.length; i += 4) { - const value = Math.random() * 255 - data[i] = value // R - data[i + 1] = value // G - data[i + 2] = value // B - data[i + 3] = 255 // A - } + const mql = window.matchMedia("(prefers-reduced-motion: reduce)") + setPrefersReducedMotion(mql.matches) - ctx.putImageData(imageData, 0, 0) - } - - const animate = () => { - frame++ - // Update noise every 2 frames for performance while still looking animated - if (frame % 2 === 0) { - generateNoise() - } - animationId = requestAnimationFrame(animate) - } - - resize() - window.addEventListener("resize", resize) - animate() + const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches) + mql.addEventListener("change", handler) + return () => mql.removeEventListener("change", handler) + }, []) - return () => { - window.removeEventListener("resize", resize) - cancelAnimationFrame(animationId) - } + useEffect(() => { + const el = containerRef.current + if (!el) return + + const observer = new IntersectionObserver( + ([entry]) => setIsVisible(entry.isIntersecting), + { threshold: 0 }, + ) + observer.observe(el) + return () => observer.disconnect() }, []) + const shouldAnimate = isVisible && !prefersReducedMotion + return ( -