Allow users to opt-in to spaces that strip signatures

This commit is contained in:
Jon Staab
2025-09-03 09:02:54 -07:00
parent a94883089e
commit 99defc6d79
13 changed files with 281 additions and 58 deletions
+2 -2
View File
@@ -49,7 +49,7 @@
import ThunkToast from "@app/components/ThunkToast.svelte"
import {
INDEXER_RELAYS,
userSettingValues,
userSettingsValues,
deriveChat,
splitChatId,
PLATFORM_NAME,
@@ -130,7 +130,7 @@
const template = templates[i]
thunks.push(
await sendWrapped({pubkeys, template, delay: $userSettingValues.send_delay + ms(i)}),
await sendWrapped({pubkeys, template, delay: $userSettingsValues.send_delay + ms(i)}),
)
}
+3 -3
View File
@@ -31,7 +31,7 @@
import ContentQuote from "@app/components/ContentQuote.svelte"
import ContentTopic from "@app/components/ContentTopic.svelte"
import ContentMention from "@app/components/ContentMention.svelte"
import {entityLink, userSettingValues} from "@app/core/state"
import {entityLink, userSettingsValues} from "@app/core/state"
interface Props {
event: any
@@ -68,7 +68,7 @@
if (!parsed || hideMediaAtDepth <= depth) return false
if (isLink(parsed) && $userSettingValues.show_media && isStartOrEnd(i)) {
if (isLink(parsed) && $userSettingsValues.show_media && isStartOrEnd(i)) {
return true
}
@@ -101,7 +101,7 @@
}
let warning = $state(
$userSettingValues.hide_sensitive && event.tags.find(nthEq(0, "content-warning"))?.[1],
$userSettingsValues.hide_sensitive && event.tags.find(nthEq(0, "content-warning"))?.[1],
)
const shortContent = $derived(
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
const back = () => history.back()
</script>
<div class="column gap-4">
<ModalHeader>
{#snippet title()}
<div>What are digital signatures?</div>
{/snippet}
</ModalHeader>
<p>
Most online services ask their users to trust them that they're being honest, and they usually
are. However, traditional social media platforms have the ability to <strong
>create forged content</strong> that can appear to be genuinely authored, but which are actually
counterfeit.
</p>
<p>
On <Link external href="https://nostr.com/">Nostr</Link>, all your content is authenticated
using <strong>digital signatures</strong>, which cryptographically tie a particular person to a
given post or message.
</p>
<p>
The result is that you don't normally have to trust service providers not to tamper with the
information flowing through the network — instead, your client software can prove that a given
piece of data is authentic.
</p>
<Button class="btn btn-primary" onclick={back}>Got it</Button>
</div>
+2 -1
View File
@@ -7,7 +7,7 @@
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {removeSpaceMembership} from "@app/core/commands"
import {removeSpaceMembership, removeTrustedRelay} from "@app/core/commands"
const {url} = $props()
@@ -18,6 +18,7 @@
try {
await removeSpaceMembership(url)
await removeTrustedRelay(url)
} finally {
loading = false
}
+86
View File
@@ -0,0 +1,86 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {remove} from "@welshman/lib"
import {displayRelayUrl} from "@welshman/util"
import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import InfoSignatures from "@app/components/InfoSignatures.svelte"
import {relaysPendingTrust} from "@app/core/state"
import {removeSpaceMembership, addTrustedRelay, removeTrustedRelay} from "@app/core/commands"
import {pushModal} from "@app/util/modal"
type Props = {
url: string
}
const {url}: Props = $props()
const showInfoSignatures = () => pushModal(InfoSignatures)
const untrustSpace = async () => {
loading = true
try {
await removeSpaceMembership(url)
await removeTrustedRelay(url)
goto("/")
} finally {
loading = false
}
}
const trustSpace = async () => {
loading = true
try {
await addTrustedRelay(url)
relaysPendingTrust.update($r => remove(url, $r))
} finally {
loading = false
}
}
let loading = $state(false)
</script>
<form class="column gap-4" onsubmit={preventDefault(trustSpace)}>
<ModalHeader>
{#snippet title()}
Do you trust this space?
{/snippet}
{#snippet info()}
<div>
Only join <span class="text-primary">{displayRelayUrl(url)}</span> if you trust the adminstrator
</div>
{/snippet}
</ModalHeader>
<div class="m-auto flex flex-col gap-4">
<p>
This space has opted not to publish <Button class="link" onclick={showInfoSignatures}
>digital signatures</Button
>, which means that they have the ability to forge messages from other users.
</p>
<p>
If you trust this space's admin, you can continue. Otherwise, it may be safer not to join this
space.
</p>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row sm:justify-between">
<Button class="btn btn-neutral" onclick={untrustSpace} disabled={loading}>
{#if !loading}
<Icon icon="close-circle" />
{/if}
<Spinner {loading}>I don't trust this space</Spinner>
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
{#if !loading}
<Icon icon="check-circle" />
{/if}
<Spinner {loading}>I trust this space, continue</Spinner>
</Button>
</div>
</form>
+34 -1
View File
@@ -1,7 +1,17 @@
import {nwc} from "@getalby/sdk"
import * as nip19 from "nostr-tools/nip19"
import {get} from "svelte/store"
import {randomId, flatten, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib"
import {
randomId,
append,
remove,
flatten,
poll,
uniq,
equals,
TIMEZONE,
LOCALE,
} from "@welshman/lib"
import type {Feed} from "@welshman/feeds"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {
@@ -19,6 +29,7 @@ import {
ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
APP_DATA,
isSignedEvent,
makeEvent,
displayProfile,
@@ -56,13 +67,16 @@ import {
tagEventForQuote,
getThunkError,
} from "@welshman/app"
import type {SettingsValues} from "@app/core/state"
import {
SETTINGS,
PROTECTED,
userMembership,
INDEXER_RELAYS,
NOTIFIER_PUBKEY,
NOTIFIER_RELAY,
userRoomsByUrl,
userSettingsValues,
} from "@app/core/state"
// Utils
@@ -461,6 +475,25 @@ export const makeAlert = async (params: AlertParams) => {
export const publishAlert = async (params: AlertParams) =>
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
// Settings
export const makeSettings = async (params: Partial<SettingsValues>) => {
const json = JSON.stringify({...userSettingsValues.get(), ...params})
const content = await signer.get().nip44.encrypt(pubkey.get()!, json)
const tags = [["d", SETTINGS]]
return makeEvent(APP_DATA, {content, tags})
}
export const publishSettings = async (params: Partial<SettingsValues>) =>
publishThunk({event: await makeSettings(params), relays: Router.get().FromUser().getUrls()})
export const addTrustedRelay = async (url: string) =>
publishSettings({trusted_relays: append(url, userSettingsValues.get().trusted_relays)})
export const removeTrustedRelay = async (url: string) =>
publishSettings({trusted_relays: remove(url, userSettingsValues.get().trusted_relays)})
// Lightning
export const getWebLn = () => (window as any).webln
+28 -15
View File
@@ -1,6 +1,6 @@
import twColors from "tailwindcss/colors"
import {Capacitor} from "@capacitor/core"
import {get, derived} from "svelte/store"
import {get, derived, writable} from "svelte/store"
import * as nip19 from "nostr-tools/nip19"
import {
on,
@@ -23,7 +23,7 @@ import {
always,
} from "@welshman/lib"
import type {Socket} from "@welshman/net"
import {Pool, load, AuthStateEvent, SocketEvent} from "@welshman/net"
import {Pool, load, AuthStateEvent, SocketEvent, netContext} from "@welshman/net"
import {
collection,
custom,
@@ -56,6 +56,7 @@ import {
ALERT_IOS,
ALERT_ANDROID,
ALERT_STATUS,
APP_DATA,
getGroupTags,
getRelayTagValues,
getPubkeyTagValues,
@@ -68,6 +69,7 @@ import {
getTag,
getTagValue,
getTagValues,
verifyEvent,
} from "@welshman/util"
import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util"
import {Nip59, decrypt} from "@welshman/signer"
@@ -304,6 +306,9 @@ appContext.dufflepudUrl = DUFFLEPUD_URL
routerContext.getIndexerRelays = always(INDEXER_RELAYS)
netContext.isEventValid = (event: TrustedEvent, url: string) =>
getSetting<string[]>("trusted_relays").includes(url) || verifyEvent(event)
// Settings
export const canDecrypt = synced({
@@ -312,23 +317,27 @@ export const canDecrypt = synced({
storage: localStorageProvider,
})
export const SETTINGS = 38489
export const SETTINGS = "flotilla/settings"
export type SettingsValues = {
show_media: boolean
hide_sensitive: boolean
trusted_relays: string[]
report_usage: boolean
report_errors: boolean
send_delay: number
font_size: number
}
export type Settings = {
event: TrustedEvent
values: {
show_media: boolean
hide_sensitive: boolean
report_usage: boolean
report_errors: boolean
send_delay: number
font_size: number
}
values: SettingsValues
}
export const defaultSettings = {
show_media: true,
hide_sensitive: true,
trusted_relays: [],
report_usage: true,
report_errors: true,
send_delay: 3000,
@@ -336,7 +345,7 @@ export const defaultSettings = {
}
export const settings = deriveEventsMapped<Settings>(repository, {
filters: [{kinds: [SETTINGS]}],
filters: [{kinds: [APP_DATA], "#d": [SETTINGS]}],
itemToEvent: item => item.event,
eventToItem: async (event: TrustedEvent) => ({
event,
@@ -352,9 +361,13 @@ export const {
name: "settings",
store: settings,
getKey: settings => settings.event.pubkey,
load: makeOutboxLoader(SETTINGS),
load: makeOutboxLoader(APP_DATA, {"#d": [SETTINGS]}),
})
// Relays sending events with empty signatures that the user has to choose to trust
export const relaysPendingTrust = writable<string[]>([])
// Alerts
export type Alert = {
@@ -610,11 +623,11 @@ export const userSettings = withGetter(
}),
)
export const userSettingValues = withGetter(
export const userSettingsValues = withGetter(
derived(userSettings, $s => $s?.values || defaultSettings),
)
export const getSetting = <T>(key: keyof Settings["values"]) => userSettingValues.get()[key] as T
export const getSetting = <T>(key: keyof Settings["values"]) => userSettingsValues.get()[key] as T
export const userMembership = withGetter(
derived([pubkey, membershipsByPubkey], ([$pubkey, $membershipsByPubkey]) => {
+3 -1
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import type {Snippet} from "svelte"
interface Props {
children?: import("svelte").Snippet
children?: Snippet
}
const {children}: Props = $props()
+70 -9
View File
@@ -9,11 +9,23 @@
import {dev} from "$app/environment"
import {goto} from "$app/navigation"
import {sync, localStorageProvider} from "@welshman/store"
import {identity, memoize, spec, sleep, defer, ago, WEEK, TaskQueue} from "@welshman/lib"
import {
identity,
call,
memoize,
spec,
sleep,
on,
defer,
ago,
WEEK,
TaskQueue,
} from "@welshman/lib"
import type {TrustedEvent, StampedEvent} from "@welshman/util"
import {
WRAP,
EVENT_TIME,
APP_DATA,
THREAD,
MESSAGE,
INBOX_RELAYS,
@@ -28,8 +40,14 @@
getRelaysFromList,
} from "@welshman/util"
import {Nip46Broker, makeSecret} from "@welshman/signer"
import type {Socket} from "@welshman/net"
import {request, defaultSocketPolicies, makeSocketPolicyAuth} from "@welshman/net"
import type {Socket, RelayMessage} from "@welshman/net"
import {
request,
defaultSocketPolicies,
makeSocketPolicyAuth,
SocketEvent,
isRelayEvent,
} from "@welshman/net"
import {
loadRelay,
db,
@@ -64,9 +82,11 @@
import {
INDEXER_RELAYS,
userMembership,
userSettingValues,
userSettingsValues,
relaysPendingTrust,
ensureUnwrapped,
canDecrypt,
getSetting,
} from "@app/core/state"
import {loadUserData, listenForNotifications} from "@app/core/requests"
import {theme} from "@app/util/theme"
@@ -162,9 +182,9 @@
})
// Sync font size
userSettingValues.subscribe($userSettingValues => {
userSettingsValues.subscribe($userSettingsValues => {
// @ts-ignore
document.documentElement.style["font-size"] = `${$userSettingValues.font_size}rem`
document.documentElement.style["font-size"] = `${$userSettingsValues.font_size}rem`
})
if (!db) {
@@ -214,9 +234,16 @@
repository,
rankEvent: (e: TrustedEvent) => {
if (
[PROFILE, FOLLOWS, MUTES, RELAYS, BLOSSOM_SERVERS, INBOX_RELAYS, ROOMS].includes(
e.kind,
)
[
PROFILE,
FOLLOWS,
MUTES,
RELAYS,
BLOSSOM_SERVERS,
INBOX_RELAYS,
ROOMS,
APP_DATA,
].includes(e.kind)
) {
return 1
}
@@ -239,6 +266,40 @@
sign: (event: StampedEvent) => signer.get()?.sign(event),
shouldAuth: (socket: Socket) => true,
}),
(socket: Socket) => {
const buffer: RelayMessage[] = []
const unsubscribers = [
// When the socket goes from untrusted to trusted, receive all buffered messages
userSettingsValues.subscribe($settings => {
if ($settings.trusted_relays.includes(socket.url)) {
for (const message of buffer.splice(0)) {
socket._recvQueue.push(message)
}
}
}),
// When we get an event with no signature from an untrusted relay, remove it from
// the receive queue. If trust status is undefined, buffer it for later.
on(socket, SocketEvent.Receiving, (message: RelayMessage) => {
if (isRelayEvent(message) && !message[2]?.sig) {
const isTrusted = getSetting<string[]>("trusted_relays").includes(socket.url)
if (!isTrusted) {
socket._recvQueue.remove(message)
buffer.push(message)
if (!$relaysPendingTrust.includes(socket.url)) {
relaysPendingTrust.update($r => [...$r, socket.url])
}
}
}
}),
]
return () => {
unsubscribers.forEach(call)
}
},
)
// Load relay info
+8 -21
View File
@@ -9,14 +9,7 @@
BLOSSOM_SERVERS,
} from "@welshman/util"
import {Router} from "@welshman/router"
import {
pubkey,
signer,
userMutes,
tagPubkey,
publishThunk,
userBlossomServers,
} from "@welshman/app"
import {userMutes, tagPubkey, publishThunk, userBlossomServers} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
@@ -24,38 +17,32 @@
import Button from "@lib/components/Button.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import {pushToast} from "@app/util/toast"
import {SETTINGS, PLATFORM_NAME, userSettingValues} from "@app/core/state"
import {PLATFORM_NAME, userSettingsValues} from "@app/core/state"
import {publishSettings} from "@app/core/commands"
const reset = () => {
settings = {...$userSettingValues}
settings = {...$userSettingsValues}
mutedPubkeys = getPubkeyTagValues(getListTags($userMutes))
blossomServers = getTagValues("server", getListTags($userBlossomServers))
}
const onsubmit = preventDefault(async () => {
const json = JSON.stringify($state.snapshot(settings))
const content = await $signer!.nip44.encrypt($pubkey!, json)
const relays = Router.get().FromUser().getUrls()
publishThunk({
event: makeEvent(SETTINGS, {content}),
relays,
})
await publishSettings($state.snapshot(settings))
publishThunk({
event: makeEvent(MUTES, {tags: mutedPubkeys.map(tagPubkey)}),
relays,
relays: Router.get().FromUser().getUrls(),
})
publishThunk({
event: makeEvent(BLOSSOM_SERVERS, {tags: blossomServers.map(tagger("server"))}),
relays,
relays: Router.get().FromUser().getUrls(),
})
pushToast({message: "Your settings have been saved!"})
})
let settings = $state({...$userSettingValues})
let settings = $state({...$userSettingsValues})
let mutedPubkeys = $state(getPubkeyTagValues(getListTags($userMutes)))
let blossomServers = $state(getTagValues("server", getListTags($userBlossomServers)))
</script>
+9 -1
View File
@@ -5,14 +5,16 @@
import {ago, MONTH} from "@welshman/lib"
import {ROOM_META, EVENT_TIME, THREAD, COMMENT, MESSAGE} from "@welshman/util"
import Page from "@lib/components/Page.svelte"
import Dialog from "@lib/components/Dialog.svelte"
import SecondaryNav from "@lib/components/SecondaryNav.svelte"
import MenuSpace from "@app/components/MenuSpace.svelte"
import SpaceAuthError from "@app/components/SpaceAuthError.svelte"
import SpaceTrustRelay from "@app/components/SpaceTrustRelay.svelte"
import {pushToast} from "@app/util/toast"
import {pushModal} from "@app/util/modal"
import {setChecked} from "@app/util/notifications"
import {checkRelayConnection, checkRelayAuth, checkRelayAccess} from "@app/core/commands"
import {decodeRelay, userRoomsByUrl} from "@app/core/state"
import {decodeRelay, userRoomsByUrl, relaysPendingTrust} from "@app/core/state"
import {pullConservatively} from "@app/core/requests"
import {notifications} from "@app/util/notifications"
@@ -82,3 +84,9 @@
{@render children?.()}
{/key}
</Page>
{#if $relaysPendingTrust.includes(url)}
<Dialog>
<SpaceTrustRelay {url} />
</Dialog>
{/if}
@@ -31,7 +31,7 @@
import ChannelComposeParent from "@app/components/ChannelComposeParent.svelte"
import {
userRoomsByUrl,
userSettingValues,
userSettingsValues,
decodeRelay,
getEventsForUrl,
deriveUserMembershipStatus,
@@ -128,7 +128,7 @@
const thunk = publishThunk({
relays: [url],
event: makeEvent(MESSAGE, template),
delay: $userSettingValues.send_delay,
delay: $userSettingsValues.send_delay,
})
pushToast({
+2 -2
View File
@@ -20,7 +20,7 @@
import ChannelCompose from "@app/components/ChannelCompose.svelte"
import ChannelComposeParent from "@app/components/ChannelComposeParent.svelte"
import {
userSettingValues,
userSettingsValues,
decodeRelay,
getEventsForUrl,
PROTECTED,
@@ -69,7 +69,7 @@
const thunk = publishThunk({
relays: [url],
event: makeEvent(MESSAGE, template),
delay: $userSettingValues.send_delay,
delay: $userSettingsValues.send_delay,
})
pushToast({