Merge pull request #931 from PR0M3TH3AN/codex/update-access-control-for-hex-pubkeys

Handle hex pubkeys in access control
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-26 15:36:19 -04:00
commit eefa0a29fe
2 files changed
+334 -6

No files matched your search

+205 -6
View File
@@ -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;
}
+129
View File
@@ -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;
}
});