mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-05 12:22:28 +00:00
Support nip44 subscription list decryption
This commit is contained in:
1 parent
2322b19a3f
commit
d7d12c3b74
2 files changed
+217
-7
No files matched your search
+132
-7
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user