Support nip44 subscription list decryption

This commit is contained in:
thePR0M3TH3AN committed 2025-10-20 21:56:18 -04:00
1 parent 2322b19a3f
commit d7d12c3b74
2 files changed
+217 -7

No files matched your search

+132 -7
View File
@@ -28,6 +28,70 @@ function normalizeHexPubkey(value) {
return trimmed ? trimmed.toLowerCase() : "";
}
function extractEncryptionHints(event) {
if (!event || typeof event !== "object") {
return [];
}
const tags = Array.isArray(event.tags) ? event.tags : [];
const hints = [];
const pushUnique = (scheme) => {
if (!scheme || hints.includes(scheme)) {
return;
}
hints.push(scheme);
};
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
const label = typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : "";
if (label !== "encrypted" && label !== "encryption") {
continue;
}
const rawValue = typeof tag[1] === "string" ? tag[1] : "";
if (!rawValue) {
continue;
}
const parts = rawValue
.split(/[\s,]+/)
.map((part) => part.trim().toLowerCase())
.filter(Boolean);
for (const part of parts) {
if (part.startsWith("nip44")) {
pushUnique("nip44");
} else if (part === "nip04" || part === "nip-04") {
pushUnique("nip04");
}
}
}
return hints;
}
function determineDecryptionOrder(event, availableSchemes) {
const available = Array.isArray(availableSchemes) ? availableSchemes : [];
const availableSet = new Set(available);
const prioritized = [];
const hints = extractEncryptionHints(event);
for (const hint of hints) {
if (availableSet.has(hint) && !prioritized.includes(hint)) {
prioritized.push(hint);
}
}
for (const fallback of ["nip04", "nip44"]) {
if (availableSet.has(fallback) && !prioritized.includes(fallback)) {
prioritized.push(fallback);
}
}
return prioritized.length ? prioritized : available;
}
const getApp = () => getApplication();
/**
@@ -112,20 +176,20 @@ class SubscriptionsManager {
return;
}
let decryptedStr = "";
try {
decryptedStr = await window.nostr.nip04.decrypt(
userPubkey,
newest.content
const decryptResult = await this.decryptSubscriptionEvent(newest, userPubkey);
if (!decryptResult.ok) {
userLogger.error(
"[SubscriptionsManager] Failed to decrypt subscription list:",
decryptResult.error,
);
} catch (errDecrypt) {
userLogger.error("[SubscriptionsManager] Decryption failed:", errDecrypt);
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.loaded = true;
return;
}
const decryptedStr = decryptResult.plaintext;
const parsed = JSON.parse(decryptedStr);
const subArray = Array.isArray(parsed.subPubkeys)
? parsed.subPubkeys
@@ -153,6 +217,67 @@ class SubscriptionsManager {
return Array.from(this.subscribedPubkeys);
}
async decryptSubscriptionEvent(event, userPubkey) {
const ciphertext = typeof event?.content === "string" ? event.content : "";
if (!ciphertext) {
const error = new Error("Subscription event is missing ciphertext content.");
error.code = "subscriptions-empty-ciphertext";
return { ok: false, error };
}
const nostrApi =
typeof window !== "undefined" && window && window.nostr ? window.nostr : null;
if (!nostrApi) {
const error = new Error("Nostr extension is unavailable for decrypting subscriptions.");
error.code = "nostr-extension-missing";
return { ok: false, error };
}
const decryptors = new Map();
if (nostrApi.nip04 && typeof nostrApi.nip04.decrypt === "function") {
decryptors.set("nip04", (payload) => nostrApi.nip04.decrypt(userPubkey, payload));
}
if (nostrApi.nip44 && typeof nostrApi.nip44.decrypt === "function") {
decryptors.set("nip44", (payload) => nostrApi.nip44.decrypt(userPubkey, payload));
}
if (!decryptors.size) {
const error = new Error("No compatible decryption helpers are available.");
error.code = "subscriptions-no-decryptors";
return { ok: false, error };
}
const availableSchemes = Array.from(decryptors.keys());
const order = determineDecryptionOrder(event, availableSchemes);
const attemptErrors = [];
for (const scheme of order) {
const decryptFn = decryptors.get(scheme);
if (!decryptFn) {
continue;
}
try {
const plaintext = await decryptFn(ciphertext);
if (typeof plaintext !== "string") {
const error = new Error("Decryption returned a non-string payload.");
error.code = "subscriptions-invalid-plaintext";
attemptErrors.push({ scheme, error });
continue;
}
return { ok: true, plaintext, scheme };
} catch (error) {
attemptErrors.push({ scheme, error });
}
}
const error = new Error("Failed to decrypt subscription list with available schemes.");
error.code = "subscriptions-decrypt-failed";
if (attemptErrors.length) {
error.cause = attemptErrors;
}
return { ok: false, error, errors: attemptErrors };
}
async addChannel(channelHex, userPubkey) {
const normalizedChannel = normalizeHexPubkey(channelHex);
if (!userPubkey) {
+85
View File
@@ -125,6 +125,91 @@ test("loadSubscriptions aggregates relay results when one rejects", async () =>
}
});
test("loadSubscriptions falls back to nip44 when hinted", async () => {
const SubscriptionsManager = subscriptions.constructor;
const manager = new SubscriptionsManager();
const originalRelays = Array.isArray(nostrClient.relays)
? [...nostrClient.relays]
: nostrClient.relays;
const originalPool = nostrClient.pool;
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
const hadWindow = typeof globalThis.window !== "undefined";
if (!hadWindow) {
globalThis.window = {};
}
const originalWindowNostr = globalThis.window.nostr;
const relayUrls = ["wss://relay-nip44.example"];
nostrClient.relays = relayUrls;
const event = {
id: "event-nip44",
created_at: 400,
content: "cipher-nip44",
tags: [["encrypted", "nip44_v2"]],
};
nostrClient.pool = {
list() {
return Promise.resolve([event]);
},
};
nostrClient.ensureExtensionPermissions = async () => ({ ok: true });
const decryptCalls = { nip04: 0, nip44: 0 };
globalThis.window.nostr = {
nip04: {
async decrypt() {
decryptCalls.nip04 += 1;
throw new Error("nip04 unavailable");
},
},
nip44: {
async decrypt(_pubkey, ciphertext) {
decryptCalls.nip44 += 1;
return JSON.stringify({ subPubkeys: ["pub-nip44"], hint: ciphertext });
},
},
};
try {
await manager.loadSubscriptions("user-pubkey-123");
assert.deepEqual(
Array.from(manager.subscribedPubkeys),
["pub-nip44"],
"nip44 decrypted subscriptions should populate the set",
);
assert.equal(manager.subsEventId, "event-nip44");
assert.equal(manager.loaded, true);
assert.equal(
decryptCalls.nip44,
1,
"nip44 decrypt should be invoked once when hinted",
);
assert.equal(
decryptCalls.nip04,
0,
"nip04 decrypt should be skipped when nip44 hint is present",
);
} finally {
nostrClient.relays = originalRelays;
nostrClient.pool = originalPool;
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
if (typeof originalWindowNostr === "undefined") {
delete globalThis.window.nostr;
} else {
globalThis.window.nostr = originalWindowNostr;
}
if (!hadWindow) {
delete globalThis.window;
}
}
});
test(
"showSubscriptionVideos waits for nostrService warm-up and refreshes after updates",
async () => {