diff --git a/js/accessControl.js b/js/accessControl.js index c3474257..02706d89 100644 --- a/js/accessControl.js +++ b/js/accessControl.js @@ -13,12 +13,45 @@ import { persistAdminState, readCachedAdminState, } from "./adminListStore.js"; +import { ensureNostrTools } from "./nostr/toolkit.js"; import { userLogger } from "./utils/logger.js"; +const HEX_KEY_PATTERN = /^[0-9a-f]{64}$/i; + function normalizeNpub(value) { return typeof value === "string" ? value.trim() : ""; } +function normalizeHexKey(value) { + if (typeof value !== "string") { + return ""; + } + + const trimmed = value.trim().toLowerCase(); + return HEX_KEY_PATTERN.test(trimmed) ? trimmed : ""; +} + +function bytesToHex(bytes) { + if (!bytes || typeof bytes !== "object") { + return ""; + } + + if (typeof bytes.length !== "number") { + return ""; + } + + let hex = ""; + for (const byte of Array.from(bytes)) { + const normalized = Number(byte); + if (!Number.isInteger(normalized) || normalized < 0 || normalized > 255) { + return ""; + } + hex += normalized.toString(16).padStart(2, "0"); + } + + return normalizeHexKey(hex); +} + function dedupeNpubs(values) { const normalized = Array.isArray(values) ? values.map(normalizeNpub) : []; return Array.from( @@ -73,7 +106,9 @@ class AccessControl { constructor() { this.editors = new Set(); this.whitelist = new Set(); + this.whitelistPubkeys = new Set(); this.blacklist = new Set(); + this.blacklistPubkeys = new Set(); this.whitelistEnabled = getWhitelistMode(); this.hasLoaded = false; this.lastError = null; @@ -112,6 +147,85 @@ class AccessControl { } } + _rebuildHexSets( + whitelistValues = [], + blacklistValues = [], + toolkitCandidate = null + ) { + const whitelistHex = new Set(); + const blacklistHex = new Set(); + + const decodeSources = []; + const addDecodeSource = (candidate) => { + if ( + candidate && + typeof candidate === "object" && + candidate.nip19 && + typeof candidate.nip19.decode === "function" && + !decodeSources.includes(candidate.nip19) + ) { + decodeSources.push(candidate.nip19); + } + }; + + addDecodeSource(toolkitCandidate); + + const scopeTools = + (typeof window !== "undefined" ? window?.NostrTools : null) || + (typeof globalThis !== "undefined" ? globalThis?.NostrTools : null) || + null; + addDecodeSource(scopeTools); + + const decodeNpubToHex = (npub) => { + const normalized = normalizeNpub(npub); + if (!normalized) { + return ""; + } + + for (const nip19 of decodeSources) { + try { + const decoded = nip19.decode(normalized); + if (!decoded || decoded.type !== "npub") { + continue; + } + const data = decoded.data; + if (typeof data === "string") { + const normalizedHex = normalizeHexKey(data); + if (normalizedHex) { + return normalizedHex; + } + } else { + const converted = bytesToHex(data); + if (converted) { + return converted; + } + } + } catch (error) { + continue; + } + } + + return ""; + }; + + for (const entry of whitelistValues) { + const decodedHex = decodeNpubToHex(entry); + if (decodedHex) { + whitelistHex.add(decodedHex); + } + } + + for (const entry of blacklistValues) { + const decodedHex = decodeNpubToHex(entry); + if (decodedHex) { + blacklistHex.add(decodedHex); + } + } + + this.whitelistPubkeys = whitelistHex; + this.blacklistPubkeys = blacklistHex; + } + _applyState(state, options = {}) { const markLoaded = options.markLoaded !== false; const editors = Array.isArray(state?.editors) ? state.editors : []; @@ -152,6 +266,37 @@ class AccessControl { }); this.blacklist = new Set(sanitizedBlacklist); + const toolkitCandidate = + (typeof window !== "undefined" ? window?.NostrTools : null) || + (typeof globalThis !== "undefined" ? globalThis?.NostrTools : null); + this._rebuildHexSets(normalizedWhitelist, sanitizedBlacklist, toolkitCandidate); + + try { + const ensured = ensureNostrTools(); + if (ensured && typeof ensured.then === "function") { + ensured + .then((tools) => { + if (!tools) { + return; + } + this._rebuildHexSets( + Array.from(this.whitelist), + Array.from(this.blacklist), + tools + ); + }) + .catch(() => {}); + } else if (ensured) { + this._rebuildHexSets( + Array.from(this.whitelist), + Array.from(this.blacklist), + ensured + ); + } + } catch (error) { + // ignore toolkit errors; we'll attempt to hydrate again on the next refresh + } + this.whitelistEnabled = getWhitelistMode(); this.lastError = null; @@ -220,7 +365,9 @@ class AccessControl { if (!this.hasLoaded && !this._hydratedFromCache) { this.editors.clear(); this.whitelist.clear(); + this.whitelistPubkeys.clear(); this.blacklist.clear(); + this.blacklistPubkeys.clear(); } throw error; } finally { @@ -497,29 +644,72 @@ class AccessControl { if (this.whitelist.has(normalized)) { return false; } + + const normalizedHexCandidate = normalizeHexKey(normalized); + if (normalizedHexCandidate) { + if (this.whitelistPubkeys.has(normalizedHexCandidate)) { + return false; + } + return this.blacklistPubkeys.has(normalizedHexCandidate); + } + return this.blacklist.has(normalized); } canAccess(candidate) { let npub = ""; + let hex = ""; + + const considerHexCandidate = (value) => { + const normalizedHex = normalizeHexKey(value); + if (!normalizedHex) { + return false; + } + hex = normalizedHex; + return true; + }; if (typeof candidate === "string") { - npub = candidate; + if (!considerHexCandidate(candidate)) { + npub = candidate; + } } else if (candidate && typeof candidate === "object") { if (typeof candidate.npub === "string") { npub = candidate.npub; } else if (typeof candidate.pubkey === "string") { - try { - npub = window.NostrTools.nip19.npubEncode(candidate.pubkey); - } catch (error) { - npub = candidate.pubkey; + if (!considerHexCandidate(candidate.pubkey)) { + try { + npub = window.NostrTools.nip19.npubEncode(candidate.pubkey); + } catch (error) { + npub = candidate.pubkey; + } } } } const normalized = normalizeNpub(npub); if (!normalized) { - return false; + if (!hex) { + return false; + } + + if (this.isLockdownActive()) { + return false; + } + + if (this.whitelistPubkeys.has(hex)) { + return true; + } + + if (this.blacklistPubkeys.has(hex)) { + return false; + } + + if (this.whitelistEnabled && !this.whitelistPubkeys.has(hex)) { + return false; + } + + return true; } if (this.isAdminEditor(normalized)) { @@ -530,6 +720,15 @@ class AccessControl { return false; } + if (hex) { + if (this.whitelistPubkeys.has(hex)) { + return true; + } + if (this.blacklistPubkeys.has(hex)) { + return false; + } + } + if (this.whitelist.has(normalized)) { return true; } diff --git a/tests/nostr-service-access-control.test.mjs b/tests/nostr-service-access-control.test.mjs new file mode 100644 index 00000000..513102e2 --- /dev/null +++ b/tests/nostr-service-access-control.test.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import "./test-helpers/setup-localstorage.mjs"; +import { AccessControl } from "../js/accessControl.js"; +import { NostrService } from "../js/services/nostrService.js"; + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +const whitelistHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const whitelistNpub = "npub1whitelistcandidate"; +const blacklistHex = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; +const blacklistNpub = "npub1blacklistcandidate"; + +const toolkit = { + nip19: { + decode(value) { + if (typeof value !== "string") { + throw new Error("invalid value"); + } + + const trimmed = value.trim(); + if (trimmed === whitelistNpub) { + return { type: "npub", data: whitelistHex }; + } + if (trimmed === blacklistNpub) { + return { type: "npub", data: blacklistHex }; + } + throw new Error("unsupported npub"); + }, + npubEncode(value) { + if (value === whitelistHex) { + return whitelistNpub; + } + if (value === blacklistHex) { + return blacklistNpub; + } + throw new Error("unsupported hex"); + }, + }, +}; + +const originalWindowNostrTools = globalThis.window.NostrTools; +const originalGlobalNostrTools = globalThis.NostrTools; + +globalThis.window.NostrTools = toolkit; +globalThis.NostrTools = toolkit; + +test.after(() => { + if (originalWindowNostrTools === undefined) { + delete globalThis.window.NostrTools; + } else { + globalThis.window.NostrTools = originalWindowNostrTools; + } + + if (originalGlobalNostrTools === undefined) { + delete globalThis.NostrTools; + } else { + globalThis.NostrTools = originalGlobalNostrTools; + } +}); + +function createServiceWithAccessControl() { + const service = new NostrService(); + const accessControl = new AccessControl(); + accessControl._hydrateFromCache = () => {}; + accessControl._scheduleHydrateFromCache = () => {}; + accessControl.whitelist = new Set([whitelistNpub]); + accessControl.whitelistPubkeys = new Set([whitelistHex]); + accessControl.blacklist = new Set([blacklistNpub]); + accessControl.blacklistPubkeys = new Set([blacklistHex]); + accessControl.whitelistEnabled = true; + service.accessControl = accessControl; + return service; +} + +test("shouldIncludeVideo returns true for whitelist author when npubEncode throws", () => { + const service = createServiceWithAccessControl(); + const originalEncode = globalThis.window.NostrTools.nip19.npubEncode; + globalThis.window.NostrTools.nip19.npubEncode = () => { + throw new Error("encode failure"); + }; + + try { + const video = { + id: "video-whitelist", + kind: 30078, + pubkey: whitelistHex, + }; + + assert.equal(service.shouldIncludeVideo(video), true); + } finally { + globalThis.window.NostrTools.nip19.npubEncode = originalEncode; + } +}); + +test("shouldIncludeVideo rejects blacklisted authors provided as npub", () => { + const service = createServiceWithAccessControl(); + const video = { + id: "video-blacklist-npub", + kind: 30078, + pubkey: blacklistHex, + npub: blacklistNpub, + }; + + assert.equal(service.shouldIncludeVideo(video), false); +}); + +test("shouldIncludeVideo rejects blacklisted authors provided as hex when npubEncode throws", () => { + const service = createServiceWithAccessControl(); + const originalEncode = globalThis.window.NostrTools.nip19.npubEncode; + globalThis.window.NostrTools.nip19.npubEncode = () => { + throw new Error("encode failure"); + }; + + try { + const video = { + id: "video-blacklist-hex", + kind: 30078, + pubkey: blacklistHex, + }; + + assert.equal(service.shouldIncludeVideo(video), false); + } finally { + globalThis.window.NostrTools.nip19.npubEncode = originalEncode; + } +});