Merge pull request #378 from PR0M3TH3AN/codex/refactor-more-menu-into-controller-ax8cjy

Fix watch history decryption permissions
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-09 12:42:04 -04:00
commit 32686c5ef0
2 files changed
+193 -10

No files matched your search

+112 -8
View File
@@ -72,6 +72,15 @@ const WATCH_HISTORY_REPUBLISH_MAX_DELAY_MS = 5 * 60 * 1000;
const WATCH_HISTORY_REPUBLISH_MAX_ATTEMPTS = 8;
const WATCH_HISTORY_REPUBLISH_JITTER = 0.25;
const DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([
"get_public_key",
"sign_event",
"nip04.encrypt",
"nip04.decrypt",
"read_relays",
"write_relays",
]);
const viewEventPublishMemory = new Map();
const rebroadcastAttemptMemory = new Map();
@@ -2886,6 +2895,99 @@ export class NostrClient {
this.watchHistoryLastCreatedAt = 0;
this.countRequestCounter = 0;
this.countUnsupportedRelays = new Set();
this.extensionPermissionCache = new Set();
}
markExtensionPermissions(methods = []) {
if (!this.extensionPermissionCache) {
this.extensionPermissionCache = new Set();
}
if (!Array.isArray(methods)) {
return;
}
for (const method of methods) {
if (typeof method !== "string") {
continue;
}
const normalized = method.trim();
if (normalized) {
this.extensionPermissionCache.add(normalized);
}
}
}
async ensureExtensionPermissions(methods = DEFAULT_NIP07_PERMISSION_METHODS) {
if (!Array.isArray(methods)) {
methods = [];
}
const normalized = Array.from(
new Set(
methods
.map((method) =>
typeof method === "string" && method.trim() ? method.trim() : "",
)
.filter(Boolean),
),
);
if (!normalized.length) {
return { ok: true };
}
const outstanding = normalized.filter((method) =>
this.extensionPermissionCache ? !this.extensionPermissionCache.has(method) : true,
);
if (!outstanding.length) {
return { ok: true };
}
const extension = typeof window !== "undefined" ? window.nostr : null;
if (!extension) {
return { ok: false, error: new Error("extension-unavailable") };
}
if (typeof extension.enable !== "function") {
this.markExtensionPermissions(outstanding);
return { ok: true, code: "enable-unavailable" };
}
const permissionVariants = [];
if (outstanding.length) {
permissionVariants.push({
permissions: outstanding.map((method) => ({ method })),
});
permissionVariants.push({ permissions: outstanding });
}
permissionVariants.push(null);
let lastError = null;
for (const options of permissionVariants) {
try {
await runNip07WithRetry(
() => (options ? extension.enable(options) : extension.enable()),
{ label: "extension.enable" },
);
this.markExtensionPermissions(outstanding);
return { ok: true };
} catch (error) {
lastError = error;
if (options && isDevMode) {
console.warn(
"[nostr] extension.enable request with explicit permissions failed:",
error,
);
}
}
}
return {
ok: false,
error: lastError || new Error("permission-denied"),
};
}
applyRootCreatedAt(video) {
@@ -3813,6 +3915,10 @@ export class NostrClient {
}
}
if (useExtensionEncrypt) {
await this.ensureExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS);
}
const canonicalItems = canonicalizeWatchHistoryItems(
Array.isArray(rawItems) ? rawItems : [],
WATCH_HISTORY_MAX_ITEMS,
@@ -4590,6 +4696,7 @@ export class NostrClient {
typeof extension?.nip04?.decrypt === "function" &&
normalizeActorKey(this.pubkey) === actorKey;
if (extensionDecrypt) {
await this.ensureExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS);
return extension.nip04.decrypt(resolvedActor, ciphertext);
}
if (!this.sessionActor || this.sessionActor.pubkey !== actorKey) {
@@ -5471,14 +5578,9 @@ export class NostrClient {
if (isDevMode) {
console.log("Requesting permissions from NIP-07 extension...");
}
const requestedPermissionMethods = [
"get_public_key",
"sign_event",
"nip04.encrypt",
"nip04.decrypt",
"read_relays",
"write_relays",
];
const requestedPermissionMethods = Array.from(
DEFAULT_NIP07_PERMISSION_METHODS,
);
const permissionVariants = [];
const objectPermissions = requestedPermissionMethods
@@ -5529,6 +5631,8 @@ export class NostrClient {
: "The NIP-07 extension denied the permission request.",
);
}
this.markExtensionPermissions(requestedPermissionMethods);
}
if (allowAccountSelection && typeof extension.selectAccounts === "function") {
+81 -2
View File
@@ -7,6 +7,8 @@ const {
WATCH_HISTORY_PAYLOAD_MAX_BYTES,
WATCH_HISTORY_MAX_ITEMS,
WATCH_HISTORY_CACHE_TTL_MS,
WATCH_HISTORY_BATCH_RESOLVE,
WATCH_HISTORY_BATCH_PAGE_SIZE,
} =
await import("../js/config.js");
const {
@@ -58,6 +60,14 @@ const originalScheduleWatchHistoryRepublish =
const originalResolveWatchHistory = nostrClient.resolveWatchHistory;
const originalGetWatchHistoryFingerprint =
nostrClient.getWatchHistoryFingerprint;
const originalExtensionPermissionCache = nostrClient.extensionPermissionCache;
const originalExtensionPermissionSnapshot = Array.isArray(
originalExtensionPermissionCache,
)
? Array.from(originalExtensionPermissionCache)
: [];
nostrClient.extensionPermissionCache = new Set();
nostrClient.watchHistoryCache = new Map();
nostrClient.watchHistoryStorage = {
@@ -255,7 +265,12 @@ function installExtensionCrypto({ actor }) {
let extensionEncrypts = 0;
let extensionDecrypts = 0;
let fallbackEncrypts = 0;
const enableCalls = [];
window.nostr = {
enable: async (options) => {
enableCalls.push(options || null);
return { ok: true };
},
signEvent: async (event) => ({
...event,
id: `ext-${event.kind}-${event.created_at}`,
@@ -304,6 +319,7 @@ function installExtensionCrypto({ actor }) {
getExtensionEncrypts: () => extensionEncrypts,
getExtensionDecrypts: () => extensionDecrypts,
getFallbackEncrypts: () => fallbackEncrypts,
getEnableCalls: () => [...enableCalls],
};
}
@@ -552,6 +568,11 @@ async function testPublishSnapshotUsesExtensionCrypto() {
result.items.length,
"decrypted payload should match canonical item count",
);
assert.equal(
extension.getEnableCalls().length,
1,
"extension permissions should be requested once before encrypting",
);
} finally {
extension.restore();
sessionRestore.restore();
@@ -561,6 +582,43 @@ async function testPublishSnapshotUsesExtensionCrypto() {
}
}
async function testEnsureExtensionPermissionCaching() {
const actor = "permission-actor";
const extension = installExtensionCrypto({ actor });
const previousCache = nostrClient.extensionPermissionCache;
nostrClient.extensionPermissionCache = new Set();
try {
const first = await nostrClient.ensureExtensionPermissions([
"nip04.decrypt",
]);
assert.equal(
first.ok,
true,
"ensureExtensionPermissions should resolve when extension is available",
);
assert.equal(
extension.getEnableCalls().length > 0,
true,
"ensureExtensionPermissions should invoke extension.enable to request access",
);
const callsAfterFirst = extension.getEnableCalls().length;
const second = await nostrClient.ensureExtensionPermissions([
"nip04.decrypt",
]);
assert.equal(second.ok, true, "cached permission requests should still resolve");
assert.equal(
extension.getEnableCalls().length,
callsAfterFirst,
"cached permission requests should not trigger duplicate enable calls",
);
} finally {
extension.restore();
nostrClient.extensionPermissionCache = previousCache;
}
}
async function testPublishSnapshotFailureRetry() {
poolHarness.reset();
nostrClient.watchHistoryLastCreatedAt = 0;
@@ -852,10 +910,22 @@ async function testResolveWatchHistoryBatchingWindow() {
forceRefresh: true,
});
const batchPageSizeRaw = Number(WATCH_HISTORY_BATCH_PAGE_SIZE);
const hasCustomBatchSize =
Boolean(WATCH_HISTORY_BATCH_RESOLVE) &&
Number.isFinite(batchPageSizeRaw) &&
batchPageSizeRaw > 0;
const expectedCount = hasCustomBatchSize
? Math.min(
Math.floor(batchPageSizeRaw),
WATCH_HISTORY_MAX_ITEMS,
syntheticItems.length,
)
: Math.min(WATCH_HISTORY_MAX_ITEMS, syntheticItems.length);
assert.equal(
resolved.length,
Math.min(WATCH_HISTORY_MAX_ITEMS, syntheticItems.length),
"batched resolve should return the full window when no page size override is configured",
expectedCount,
"batched resolve should honor the configured page size when batching is enabled",
);
assert(resolved.length > 1, "resolved history should include multiple entries");
assert.equal(
@@ -1321,6 +1391,7 @@ async function testWatchHistoryAppLoginFallback() {
await testPublishSnapshotCanonicalizationAndChunking();
await testPublishSnapshotUsesExtensionCrypto();
await testEnsureExtensionPermissionCaching();
await testPublishSnapshotFailureRetry();
await testWatchHistoryPartialRelayRetry();
await testResolveWatchHistoryBatchingWindow();
@@ -1351,6 +1422,14 @@ nostrClient.resolveWatchHistory = originalResolveWatchHistory;
nostrClient.getWatchHistoryFingerprint =
originalGetWatchHistoryFingerprint;
if (originalExtensionPermissionCache instanceof Set) {
originalExtensionPermissionCache.clear();
for (const method of originalExtensionPermissionSnapshot) {
originalExtensionPermissionCache.add(method);
}
}
nostrClient.extensionPermissionCache = originalExtensionPermissionCache;
if (!originalFlag) {
setWatchHistoryV2Enabled(false);
}