fix: deterministic relay merge and privacy hardening

This commit is contained in:
SkateSpots 2026-10-31 09:54:36 +07:00
parent 5a1d849681
commit 42d25818b4
12 changed files with 327 additions and 31 deletions

View file

@ -2415,6 +2415,26 @@ Rules:
- Any time the deploy output isn't verified by the smoke test, run - Any time the deploy output isn't verified by the smoke test, run
`node scripts/smoke-test.mjs` manually before shipping. `node scripts/smoke-test.mjs` manually before shipping.
### Trust, privacy and consistency invariants
- **No BFT / no relay quorum.** Nostr events are signed facts; relays are not
voters. Social feeds resolve conflicts locally, not by consensus.
- **Deterministic merge:** `src/lib/eventMerge.ts` is the single merge policy
(newest `created_at`, event-id tie-break, local tombstones). Do not add
"first relay wins" logic; relay arrival order must not change the result.
- **Trust boundaries are documented** in `docs/THREAT_MODEL.md`. Keep UI copy
honest: a confirmed trade means both parties signed; a payment proof means the
money leg was paid; escrow is time-locked, not arbitration.
- **No remote hotlinks for small brand assets:** OpenSats icons are local under
`public/images/` so rendering the footer does not leak the viewer's IP.
- **Android Auto Backup is off** (`android:allowBackup="false"` injected by
`scripts/inject-backup-policy.mjs`); identity/settings/escrow state stays on
the device and must be exported explicitly.
- **Image uploads re-encode decodable formats through canvas** to strip
EXIF/GPS before Blossom. GIF and HEIC/HEIF are passed through because they need
special handling; do not claim they are metadata-stripped.
- **No mandatory identity, deposit or global trust score.** Use warnings,
explicit consent and optional escrow at value boundaries.
### Distribution without GitHub ### Distribution without GitHub
> **Owner decision (2026-10-30): no external identities.** Distribution must > **Owner decision (2026-10-30): no external identities.** Distribution must
> stay self-hosted and Nostr-based. Do **not** create or ask for GitLab, > stay self-hosted and Nostr-based. Do **not** create or ask for GitLab,

104
docs/THREAT_MODEL.md Normal file
View file

@ -0,0 +1,104 @@
# SkateSpots threat model
This is the short, technical version of what SkateSpots can and cannot guarantee.
It exists so product copy, support answers and future code do not accidentally
promise consensus, arbitration or delivery verification that the system does not
have.
## The model in one sentence
**SkateSpots verifies signed facts, keeps a local view, and uses social/economic
signals at value boundaries. It does not run a consensus protocol.**
Nostr relays are not voters. A relay can withhold, censor, reorder or serve a
stale event, but it cannot forge a valid signature. Different clients can
therefore see different snapshots of the network without either side being
"wrong" in a Byzantine-consensus sense.
## Assets
- Nostr identity keys and local signer access.
- Local cache, drafts, pending posts and local deletion tombstones.
- Crew membership and Cordn group membership.
- Marketplace listings, offers, trade attestations and reputation.
- Cashu proofs and escrow tokens.
- Device storage and any local backup/export.
## Actors and trust boundaries
| Actor | Can do | Cannot do |
| --- | --- | --- |
| Author | Sign events with their key | Forge another author's signature |
| Relay | Withhold, censor, reorder, serve stale or conflicting signed events | Forge a valid event; guarantee availability |
| Cordn coordinator | Route opaque group ciphertext; drop or delay messages | Read plaintext; forge an author proof |
| Cashu mint | Issue/redeem proofs, enforce locktime, freeze or become insolvent | Spend a correctly locked P2PK proof without a valid witness |
| Founder/admin | Modify the crew record, moderate crew content | Forge members' signatures; guarantee global agreement |
| Buyer/seller | Publish attestations and payment/receipt claims | Make a false claim true by signing it |
| WebView / host | Run the client | Be assumed trustworthy after device compromise |
## Guarantees
- **Authenticity:** a valid Schnorr signature proves which key signed an event,
not that the content is true.
- **Availability:** the client can use cached data and multiple relays; there is
no guarantee that every relay has every event.
- **Local determinism:** when two valid versions of an addressable or replaceable
event exist, the client chooses the newest `created_at`, with an event-id
tie-breaker. This is deterministic, not globally agreed.
- **Deletion:** NIP-09 is a request. Local tombstones prevent a deleted item from
flashing back on that device; relays may ignore the request.
- **Payments:** a Lightning preimage proves the invoice was paid. It does not
prove goods were delivered.
- **Escrow:** a time-locked Cashu P2PK token protects the buyer after the lock
expires and lets the seller redeem on delivery. It is not arbitration and does
not prove delivery.
- **Reputation:** reciprocal signed attestations, distinct partners and
web-of-trust weighting are evidence. Two colluding accounts can still create a
fake trade; the app must not present that as proof of goods.
## Non-guarantees
- No global ordering or consensus over relays.
- No protection from a compromised identity key, signer or device.
- No guaranteed delivery or carrier verification.
- No neutral dispute resolution.
- No guarantee that every relay honours deletion or moderation.
- No guarantee that a mint remains solvent, online or P2PK-compatible.
- No anonymity guarantee: public events, relays, image hosts and map/weather
providers may observe network metadata.
- No guarantee that a Cordn coordinator cannot censor a group. It cannot read or
forge messages, but it can drop them.
## Cordn specifically
The current Cordn group scheme is NIP-44 group delivery with a signed author
proof inside the ciphertext. It is not full MLS yet: it does not provide MLS
forward secrecy or post-compromise security. A coordinator cannot read the
message or impersonate the author, but it can refuse to relay it.
## Marketplace safety rules
- Prefer escrowed or in-person deals for high-value trades.
- Never send a plain, unlocked Cashu token in a public event.
- Treat "confirmed trade" as "both parties signed", not "goods verified".
- Treat "payment verified" as "the money leg is verified".
- A new account with no distinct confirmed partners is a warning, not a ban.
- Cash, barter and off-platform settlement have no on-chain proof; use
reputation and judgement.
## Privacy boundaries
- Public Nostr events are public forever in practice; deletion is best-effort.
- DMs and Cordn ciphertext are encrypted, but metadata (who, when, which group)
may be visible to relays/coordinators depending on the feature.
- Images are public once uploaded; strip metadata before upload and do not put
addresses in public listing images.
- Self-hosted infrastructure avoids third-party analytics, but network observers
can still see traffic to relays, image hosts, map tiles and weather services.
## What would change this model
A real consensus or arbitration layer would need a separate design with explicit
participants, quorum, liveness, slashing/appeals and privacy trade-offs. It
should not be smuggled in as "trust" copy or a global score. Until then:
**signed facts, local merge, explicit boundaries, no fake guarantees.**

View file

@ -21,11 +21,11 @@
"check:i18n": "node scripts/check-i18n.mjs", "check:i18n": "node scripts/check-i18n.mjs",
"test": "npm i --silent && tsc --noEmit && tsc -p cvm-server/tsconfig.json && tsc -p ai-proxy/tsconfig.json && tsc -p push-service/tsconfig.json && eslint && node scripts/check-i18n.mjs && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'", "test": "npm i --silent && tsc --noEmit && tsc -p cvm-server/tsconfig.json && tsc -p ai-proxy/tsconfig.json && tsc -p push-service/tsconfig.json && eslint && node scripts/check-i18n.mjs && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
"deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup", "deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup",
"android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs", "android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-backup-policy.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs",
"android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs", "android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-backup-policy.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs",
"android:open": "npx cap open android", "android:open": "npx cap open android",
"android:version": "node scripts/sync-android-version.mjs", "android:version": "node scripts/sync-android-version.mjs",
"android:build": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs && cd android && ./gradlew assembleRelease", "android:build": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-backup-policy.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs && cd android && ./gradlew assembleRelease",
"android:build:apk": "CAPACITOR_BUILD=1 vite build && sed -i 's|<script type=\"module\" src=\"/shakespeare_tailwind[^\"]*\\.js\"></script>||g' dist/index.html && sed -i 's|<style type=\"text/tailwindcss\">|<style>|g' dist/index.html && cp dist/index.html dist/404.html", "android:build:apk": "CAPACITOR_BUILD=1 vite build && sed -i 's|<script type=\"module\" src=\"/shakespeare_tailwind[^\"]*\\.js\"></script>||g' dist/index.html && sed -i 's|<style type=\"text/tailwindcss\">|<style>|g' dist/index.html && cp dist/index.html dist/404.html",
"newsletter:weekly": "tsx scripts/newsletter-weekly.ts", "newsletter:weekly": "tsx scripts/newsletter-weekly.ts",
"translation:matrix": "node scripts/translation-matrix.mjs" "translation:matrix": "node scripts/translation-matrix.mjs"

BIN
public/images/opensats.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View file

@ -0,0 +1,41 @@
#!/usr/bin/env node
/**
* Disable Android Auto Backup for SkateSpots.
*
* The app stores identity material, local settings, pending escrow tokens and
* other private state in its WebView storage. Android Auto Backup can copy that
* data to a cloud account without the user ever asking for a backup. Keep it
* device-local: SkateSpots has its own explicit export/backup flows.
*
* Idempotent; exits quietly when android/ has not been generated yet.
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const manifestPath = join(root, 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
if (!existsSync(manifestPath)) {
console.log('[inject-backup-policy] android/ not generated yet — skipping.');
process.exit(0);
}
let manifest = readFileSync(manifestPath, 'utf8');
const before = manifest;
if (/android:allowBackup="[^"]*"/.test(manifest)) {
manifest = manifest.replace(/android:allowBackup="[^"]*"/, 'android:allowBackup="false"');
} else {
manifest = manifest.replace(
/<application\b/,
'<application android:allowBackup="false"',
);
}
if (manifest === before) {
console.log('[inject-backup-policy] allowBackup already disabled.');
} else {
writeFileSync(manifestPath, manifest);
console.log('[inject-backup-policy] ✓ Disabled Android cloud backup (allowBackup=false).');
}

View file

@ -1223,7 +1223,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
<span>{t('chromeUi.notSponsoredBy')}</span> <span>{t('chromeUi.notSponsoredBy')}</span>
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<img <img
src="https://opensats.org/favicon.ico" src="/images/opensats.ico"
alt="OpenSats" alt="OpenSats"
className="h-4 w-4" className="h-4 w-4"
/> />

View file

@ -32,7 +32,7 @@ export function MapFooterStrip() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="flex items-center gap-1.5 text-gray-500"> <span className="flex items-center gap-1.5 text-gray-500">
{t('chromeUi.notSponsoredBy')} {t('chromeUi.notSponsoredBy')}
<img src="https://opensats.org/favicon.ico" alt="OpenSats" className="h-3 w-3" /> <img src="/images/opensats.ico" alt="OpenSats" className="h-3 w-3" />
<span className="font-medium text-gray-400">OpenSats</span> <span className="font-medium text-gray-400">OpenSats</span>
</span> </span>
</div> </div>

View file

@ -4,30 +4,27 @@ import { useAppContext } from '@/hooks/useAppContext';
import { useRelays } from '@/lib/relays'; import { useRelays } from '@/lib/relays';
import { useCitrine } from '@/hooks/useCitrine'; import { useCitrine } from '@/hooks/useCitrine';
import { SKATE_RELAY } from '@/lib/types'; import { SKATE_RELAY } from '@/lib/types';
import { mergeEvents } from '@/lib/eventMerge';
import { getLocalDeletions } from '@/lib/localDeletions';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
/** /**
* Deduplicate a batch of events from several relays, keeping the newest per * Merge relay results deterministically and drop this device's local deletion
* identity: * tombstones. Consensus is deliberately absent: relays are not voters, and this
* - addressable (has `d`): `pubkey:kind:d-tag`; * client must not let relay timing decide which version of an event is shown.
* - replaceable (0, 3, 10000–19999): `pubkey:kind` (NIP-01 keeps only the
* latest per author+kind — keying by id would surface stale profiles/relay
* lists when relays return different versions);
* - otherwise: the event id.
*/ */
function dedupeEvents(events: NostrEvent[]): NostrEvent[] { function dedupeEvents(events: NostrEvent[]): NostrEvent[] {
const map = new Map<string, NostrEvent>(); const deleted = getLocalDeletions();
for (const event of events) { return mergeEvents(events, {
const dTag = event.tags.find(([name]) => name === 'd')?.[1]; isDeleted: (event) => {
let key: string; const dTag = event.tags.find(([name]) => name === 'd')?.[1];
if (dTag) key = `${event.pubkey}:${event.kind}:${dTag}`; const coord = dTag !== undefined ? `${event.kind}:${event.pubkey}:${dTag}` : undefined;
else if (event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000)) { return !!(
key = `${event.pubkey}:${event.kind}`; (coord && deleted.has(coord)) ||
} else key = event.id; deleted.has(`e:${event.id}`)
const existing = map.get(key); );
if (!existing || event.created_at > existing.created_at) map.set(key, event); },
} });
return Array.from(map.values());
} }
/** /**

View file

@ -59,11 +59,8 @@ async function prepareImage(file: File): Promise<File> {
try { try {
const bitmap = await createImageBitmap(file); const bitmap = await createImageBitmap(file);
const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height)); const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height));
// Already small enough: keep the original bytes/format. // Always redraw through a canvas, even for images that are already small:
if (scale === 1 && file.size <= 1_200_000) { // this strips EXIF/GPS and other metadata before the file leaves the device.
bitmap.close();
return file;
}
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(bitmap.width * scale)); canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale)); canvas.height = Math.max(1, Math.round(bitmap.height * scale));
@ -74,7 +71,7 @@ async function prepareImage(file: File): Promise<File> {
const blob = await new Promise<Blob | null>((resolve) => { const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/webp', 0.84); canvas.toBlob(resolve, 'image/webp', 0.84);
}); });
if (!blob || blob.size >= file.size) return file; if (!blob) return file;
const base = file.name.replace(/\.[^.]+$/, '') || 'image'; const base = file.name.replace(/\.[^.]+$/, '') || 'image';
return new File([blob], `${base}.webp`, { type: 'image/webp', lastModified: file.lastModified }); return new File([blob], `${base}.webp`, { type: 'image/webp', lastModified: file.lastModified });
} catch { } catch {

View file

@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import type { NostrEvent } from '@nostrify/nostrify';
import { eventMergeKey, eventReplaces, mergeEvents } from './eventMerge';
function ev(partial: Partial<NostrEvent> & { id: string }): NostrEvent {
return {
pubkey: 'a'.repeat(64),
created_at: 100,
kind: 1,
tags: [],
content: '',
sig: '0'.repeat(128),
...partial,
} as NostrEvent;
}
describe('eventMerge', () => {
it('keys addressable, replaceable and regular events correctly', () => {
expect(eventMergeKey(ev({ id: '1', kind: 30818, tags: [['d', 'spot']] }))).toBe(
`${'a'.repeat(64)}:30818:spot`,
);
expect(eventMergeKey(ev({ id: '2', kind: 0 }))).toBe(`${'a'.repeat(64)}:0`);
expect(eventMergeKey(ev({ id: '3', kind: 1 }))).toBe('3');
});
it('keeps the newest addressable version regardless of input order', () => {
const old = ev({ id: 'b', kind: 30818, tags: [['d', 'spot']], created_at: 100 });
const fresh = ev({ id: 'a', kind: 30818, tags: [['d', 'spot']], created_at: 101 });
expect(mergeEvents([old, fresh])).toEqual([fresh]);
expect(mergeEvents([fresh, old])).toEqual([fresh]);
});
it('uses event id as a deterministic tie-breaker', () => {
const first = ev({ id: 'aaa', kind: 30000, tags: [['d', 'x']], created_at: 100 });
const second = ev({ id: 'zzz', kind: 30000, tags: [['d', 'x']], created_at: 100 });
expect(eventReplaces(first, second)).toBe(true);
expect(mergeEvents([first, second])).toEqual([second]);
expect(mergeEvents([second, first])).toEqual([second]);
});
it('keeps distinct regular events and deduplicates by id', () => {
const a = ev({ id: 'a' });
const b = ev({ id: 'b' });
expect(mergeEvents([a, b, a])).toHaveLength(2);
});
it('drops local tombstones', () => {
const a = ev({ id: 'a', kind: 30818, tags: [['d', 'spot']] });
const b = ev({ id: 'b', kind: 30818, tags: [['d', 'other']] });
expect(mergeEvents([a, b], { isDeleted: (event) => event.id === 'a' })).toEqual([b]);
});
});

74
src/lib/eventMerge.ts Normal file
View file

@ -0,0 +1,74 @@
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Deterministic event merging.
*
* Nostr does not provide global ordering: relays can return the same logical
* event set in a different order, and two relays can serve different versions
* of an addressable event. We do not attempt consensus; we make the client's
* local answer deterministic and explainable.
*
* Rules:
* - addressable events (`d` tag): newest `created_at` wins;
* - replaceable events (kind 0/3 and 10000-19999): newest `created_at` wins;
* - regular events: identity is the event id;
* - equal timestamps: lexicographically larger event id wins, so relay order
* and Promise timing cannot change the result.
*
* Local NIP-09 tombstones are applied by the caller before/while merging; a
* tombstone is a device-local safety net, not a claim that every relay erased
* the event.
*/
export type EventMergePredicate = (event: NostrEvent) => boolean;
export interface EventMergeOptions {
/** Return true for events that must not appear (local tombstones, moderation). */
isDeleted?: EventMergePredicate;
}
/** Stable identity used to compare two versions of the same logical event. */
export function eventMergeKey(event: NostrEvent): string {
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
if (dTag !== undefined) return `${event.pubkey}:${event.kind}:${dTag}`;
if (event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000)) {
return `${event.pubkey}:${event.kind}`;
}
return event.id;
}
/**
* Return true when `candidate` should replace `current` for the same key.
* Larger ids win ties; this is arbitrary but stable across relays and devices.
*/
export function eventReplaces(current: NostrEvent, candidate: NostrEvent): boolean {
if (candidate.created_at !== current.created_at) {
return candidate.created_at > current.created_at;
}
return candidate.id > current.id;
}
/**
* Merge events from one or more relays into one deterministic list.
*
* The returned list is sorted by key only for stable snapshots; callers that
* care about display order should sort for their own UI.
*/
export function mergeEvents(
events: readonly NostrEvent[],
options: EventMergeOptions = {},
): NostrEvent[] {
const { isDeleted } = options;
const byKey = new Map<string, NostrEvent>();
for (const event of events) {
if (isDeleted?.(event)) continue;
const key = eventMergeKey(event);
const current = byKey.get(key);
if (!current || eventReplaces(current, event)) {
byKey.set(key, event);
}
}
return [...byKey.values()];
}

View file

@ -115,12 +115,20 @@ export default function TrustPage() {
<p>{t('trustUi.povDesc')}</p> <p>{t('trustUi.povDesc')}</p>
</Section> </Section>
<div className="flex justify-center pt-2"> <div className="flex flex-col items-center gap-3 pt-2">
<Link to="/settings"> <Link to="/settings">
<Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90"> <Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90">
{t('trustUi.settingsCta')} {t('trustUi.settingsCta')}
</Button> </Button>
</Link> </Link>
<a
href="https://git.skate-spots.org/skatespots/SkateSpots/blob/main/docs/THREAT_MODEL.md"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-gray-500 underline decoration-dotted underline-offset-2 hover:text-gray-300"
>
{t('common.learnMore')}
</a>
</div> </div>
</div> </div>
); );