Handle dual NIP-44/NIP-04 watch history encryption

This commit is contained in:
thePR0M3TH3AN committed 2025-10-30 11:17:39 -04:00
1 parent 4cba776c7c
commit cd3c3824b9
4 files changed
+930 -270

No files matched your search

+7 -4
View File
@@ -210,10 +210,13 @@ Active identifiers include the default `WATCH_HISTORY_LIST_IDENTIFIER`
`WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS`. Chunk events derive their `d` tag from
`<snapshotId>:<index>`, advertise `['snapshot', <id>]`, and carry `['chunk', <index>, <total>]`
plus an optional leading `['head','1']` marker so relays can prioritize the first
ciphertext. All chunk content is encrypted with NIP-04 and stores only pointer
entries; richer metadata remains on-device via the
[`WatchHistoryService`](../js/watchHistoryService.js) APIs, which default to
pointer-only writes and local-only metadata caches.【F:config/instance-config.js†L60-L78】【F:js/nostrEventSchemas.js†L157-L189】【F:js/nostr/watchHistory.js†L1343-L1400】【F:js/watchHistoryService.js†L331-L376】
ciphertext. Chunk content is encrypted with the strongest mutually supported
scheme: clients probe for NIP-44 v2 first, fall back to NIP-44, and finally use
NIP-04 for legacy compatibility. The negotiated value is written back to the
`['encrypted', ...]` tag so other readers can attempt the same scheme before
falling back. All payloads store only pointer entries; richer metadata remains
on-device via the [`WatchHistoryService`](../js/watchHistoryService.js) APIs,
which default to pointer-only writes and local-only metadata caches.【F:config/instance-config.js†L60-L78】【F:js/nostrEventSchemas.js†L157-L189】【F:js/nostr/watchHistory.js†L1380-L1549】【F:js/watchHistoryService.js†L331-L376】
Refer to the [`WatchHistoryService`](../js/watchHistoryService.js) for queue
management hooks, manual snapshot helpers, and metadata toggle controls that
+617 -181
View File
@@ -34,6 +34,11 @@ const WATCH_HISTORY_REPUBLISH_BASE_DELAY_MS = 2000;
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 WATCH_HISTORY_ENCRYPTION_FALLBACK_ORDER = Object.freeze([
"nip44_v2",
"nip44",
"nip04",
]);
function cloneVideoMetadata(video) {
if (!video || typeof video !== "object") {
@@ -657,6 +662,239 @@ function looksLikeJsonStructure(content) {
return first === "{" || first === "[";
}
function hexToBytesCompat(hex, tools = null) {
if (typeof hex !== "string") {
throw new Error("Invalid hex input.");
}
const trimmed = hex.trim();
if (!trimmed || trimmed.length % 2 !== 0) {
throw new Error("Invalid hex input.");
}
if (tools?.utils && typeof tools.utils.hexToBytes === "function") {
return tools.utils.hexToBytes(trimmed);
}
const bytes = new Uint8Array(trimmed.length / 2);
for (let index = 0; index < trimmed.length; index += 2) {
const byte = Number.parseInt(trimmed.slice(index, index + 2), 16);
if (Number.isNaN(byte)) {
throw new Error("Invalid hex input.");
}
bytes[index / 2] = byte;
}
return bytes;
}
function createNip44CipherSuite(tools, privateKeyHex, targetPubkeyHex) {
if (!tools || !privateKeyHex || !targetPubkeyHex) {
return null;
}
const normalizedPrivateKey =
typeof privateKeyHex === "string" && privateKeyHex.trim()
? privateKeyHex.trim().toLowerCase()
: "";
const normalizedTarget =
typeof targetPubkeyHex === "string" && targetPubkeyHex.trim()
? targetPubkeyHex.trim().toLowerCase()
: "";
if (!normalizedPrivateKey || !normalizedTarget) {
return null;
}
let privateKeyBytes;
try {
privateKeyBytes = hexToBytesCompat(normalizedPrivateKey, tools);
} catch (_) {
return null;
}
const nip44 = tools?.nip44 && typeof tools.nip44 === "object" ? tools.nip44 : null;
const suite = {};
const nip44v2 = nip44?.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
if (
nip44v2 &&
typeof nip44v2.encrypt === "function" &&
typeof nip44v2.decrypt === "function" &&
typeof nip44v2?.utils?.getConversationKey === "function"
) {
let cachedKey = null;
const ensureKey = () => {
if (!cachedKey) {
cachedKey = nip44v2.utils.getConversationKey(privateKeyBytes, normalizedTarget);
}
return cachedKey;
};
suite.v2 = {
encrypt: (plaintext) => nip44v2.encrypt(plaintext, ensureKey()),
decrypt: (ciphertext) => nip44v2.decrypt(ciphertext, ensureKey()),
};
}
const legacyGetConversationKey = (() => {
if (typeof nip44?.getConversationKey === "function") {
return nip44.getConversationKey.bind(nip44);
}
if (typeof nip44?.utils?.getConversationKey === "function") {
return nip44.utils.getConversationKey.bind(nip44.utils);
}
return null;
})();
if (
typeof nip44?.encrypt === "function" &&
typeof nip44?.decrypt === "function" &&
typeof legacyGetConversationKey === "function"
) {
let cachedKey = null;
const ensureLegacyKey = () => {
if (!cachedKey) {
cachedKey = legacyGetConversationKey(privateKeyBytes, normalizedTarget);
}
return cachedKey;
};
suite.legacy = {
encrypt: (plaintext) => nip44.encrypt(plaintext, ensureLegacyKey()),
decrypt: (ciphertext) => nip44.decrypt(ciphertext, ensureLegacyKey()),
};
}
if (!suite.v2 && !suite.legacy) {
return null;
}
return suite;
}
function normalizeEncryptionTagValue(value) {
if (typeof value !== "string") {
return "";
}
const normalized = value.trim().toLowerCase();
switch (normalized) {
case "nip04":
case "nip-04":
case "nip4":
return "nip04";
case "nip44_v2":
case "nip44-v2":
case "nip44v2":
return "nip44_v2";
case "nip44_v1":
case "nip44-v1":
case "nip44v1":
case "nip44":
return "nip44";
default:
return "";
}
}
function extractWatchHistoryEncryptionHints(event, ciphertext) {
const hints = [];
const pushUnique = (hint) => {
if (hint && !hints.includes(hint)) {
hints.push(hint);
}
};
const tags = Array.isArray(event?.tags) ? event.tags : [];
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") {
continue;
}
const normalizedValue = normalizeEncryptionTagValue(tag[1]);
if (normalizedValue) {
pushUnique(normalizedValue);
}
}
if (!hints.length && isNip04EncryptedWatchHistoryEvent(event, ciphertext)) {
pushUnique("nip04");
}
return hints;
}
function determineWatchHistoryDecryptionOrder(
event,
ciphertext,
availableSchemes = [],
) {
const available = Array.isArray(availableSchemes) ? availableSchemes : [];
const availableSet = new Set(available);
const prioritized = [];
const hints = extractWatchHistoryEncryptionHints(event, ciphertext);
const aliasMap = {
nip04: ["nip04"],
nip44: ["nip44", "nip44_v2"],
nip44_v2: ["nip44_v2", "nip44"],
};
for (const hint of hints) {
const candidates = Array.isArray(aliasMap[hint]) ? aliasMap[hint] : [hint];
for (const candidate of candidates) {
if (availableSet.has(candidate) && !prioritized.includes(candidate)) {
prioritized.push(candidate);
break;
}
}
}
for (const fallback of WATCH_HISTORY_ENCRYPTION_FALLBACK_ORDER) {
if (availableSet.has(fallback) && !prioritized.includes(fallback)) {
prioritized.push(fallback);
}
}
return prioritized.length ? prioritized : available;
}
async function resolveNostrToolkit(deps = {}, cacheRef = null) {
if (cacheRef?.current) {
return cacheRef.current;
}
const ensureToolkit =
typeof deps.ensureNostrTools === "function" ? deps.ensureNostrTools : ensureNostrTools;
if (ensureToolkit) {
try {
const ensured = await ensureToolkit();
if (ensured) {
if (cacheRef) {
cacheRef.current = ensured;
}
return ensured;
}
} catch (error) {
devLogger.warn("[nostr] Failed to resolve nostr-tools for watch history:", error);
}
}
const readCachedToolkit =
typeof deps.getCachedNostrTools === "function"
? deps.getCachedNostrTools
: getCachedNostrTools;
if (readCachedToolkit) {
try {
const cached = await readCachedToolkit();
if (cached) {
if (cacheRef) {
cacheRef.current = cached;
}
return cached;
}
} catch (error) {
devLogger.warn("[nostr] Failed to read cached nostr-tools for watch history:", error);
}
}
return cacheRef?.current || null;
}
function mergeWatchHistoryItemsWithFallback(parsed, fallbackItems) {
if (!parsed || typeof parsed !== "object") {
return {
@@ -716,8 +954,11 @@ export function isNip04EncryptedWatchHistoryEvent(pointerEvent, ciphertext) {
return false;
}
const ivIndex = normalizedCiphertext.indexOf("?iv=");
const baseSegment = ivIndex >= 0 ? normalizedCiphertext.slice(0, ivIndex) : normalizedCiphertext;
const ivSegment = ivIndex >= 0 ? normalizedCiphertext.slice(ivIndex + 4) : "";
if (ivIndex < 0) {
return false;
}
const baseSegment = normalizedCiphertext.slice(0, ivIndex);
const ivSegment = normalizedCiphertext.slice(ivIndex + 4);
const base64Regex = /^[A-Za-z0-9+/]+={0,2}$/;
if (!baseSegment || !base64Regex.test(baseSegment)) {
return false;
@@ -1149,12 +1390,14 @@ class WatchHistoryManager {
normalizedLogged === actorKey &&
signer &&
typeof signer.signEvent === "function";
const useActiveSignerEncrypt =
canUseActiveSignerSign && signer && typeof signer.nip04Encrypt === "function";
const signerSupportsEncryption =
canUseActiveSignerSign &&
signer &&
(typeof signer.nip04Encrypt === "function" || typeof signer.nip44Encrypt === "function");
const activeSigner = canUseActiveSignerSign ? signer : null;
const encryptionSigner = useActiveSignerEncrypt ? signer : null;
const encryptionSigner = signerSupportsEncryption ? signer : null;
if (
(canUseActiveSignerSign || useActiveSignerEncrypt) &&
(canUseActiveSignerSign || signerSupportsEncryption) &&
this.deps.shouldRequestExtensionPermissions?.(signer)
) {
await this.deps.ensureExtensionPermissions?.(DEFAULT_NIP07_PERMISSION_METHODS);
@@ -1225,67 +1468,161 @@ class WatchHistoryManager {
source: options.source || "unknown",
});
const createdAtBase = Math.max(Math.floor(Date.now() / 1000), this.lastCreatedAt + 1);
let cachedNip04Tools = null;
const ensureNip04Tools = async () => {
if (cachedNip04Tools) {
return cachedNip04Tools;
const toolkitCache = { current: null };
const encryptionCandidates = [];
const registerEncryptor = (scheme, handler, source) => {
if (!scheme || typeof handler !== "function") {
return;
}
const ensureToolkit =
typeof this.deps.ensureNostrTools === "function"
? this.deps.ensureNostrTools
: ensureNostrTools;
if (ensureToolkit) {
try {
const ensured = await ensureToolkit();
if (
ensured?.nip04 &&
typeof ensured.nip04.encrypt === "function"
) {
cachedNip04Tools = ensured;
return cachedNip04Tools;
}
} catch (error) {
devLogger.warn(
"[nostr] Failed to resolve nostr-tools for watch history:",
error,
);
}
}
const readCachedToolkit =
typeof this.deps.getCachedNostrTools === "function"
? this.deps.getCachedNostrTools
: getCachedNostrTools;
if (readCachedToolkit) {
try {
const cached = await readCachedToolkit();
if (
cached?.nip04 &&
typeof cached.nip04.encrypt === "function"
) {
cachedNip04Tools = cached;
return cachedNip04Tools;
}
} catch (error) {
devLogger.warn(
"[nostr] Failed to read cached nostr-tools for watch history:",
error,
);
}
}
return null;
encryptionCandidates.push({
scheme,
handler,
source: source || "unknown",
});
};
const encryptChunk = async (plaintext) => {
if (encryptionSigner) {
return encryptionSigner.nip04Encrypt(actorPubkey, plaintext);
if (encryptionSigner) {
if (typeof encryptionSigner.nip44Encrypt === "function") {
registerEncryptor(
"nip44_v2",
(plaintext) => encryptionSigner.nip44Encrypt(actorPubkey, plaintext),
"active-signer",
);
registerEncryptor(
"nip44",
(plaintext) => encryptionSigner.nip44Encrypt(actorPubkey, plaintext),
"active-signer",
);
}
const tools = await ensureNip04Tools();
if (!tools?.nip04 || typeof tools.nip04.encrypt !== "function") {
throw new Error("nip04-unavailable");
if (typeof encryptionSigner.nip04Encrypt === "function") {
registerEncryptor(
"nip04",
(plaintext) => encryptionSigner.nip04Encrypt(actorPubkey, plaintext),
"active-signer",
);
}
return tools.nip04.encrypt(privateKey, actorPubkey, plaintext);
}
const ensureSessionEncryptors = async () => {
if (!privateKey) {
return;
}
const tools = await resolveNostrToolkit(this.deps, toolkitCache);
if (!tools) {
return;
}
const suite = createNip44CipherSuite(tools, privateKey, actorKey);
if (suite?.v2?.encrypt) {
registerEncryptor(
"nip44_v2",
(plaintext) => suite.v2.encrypt(plaintext),
"session-nip44_v2",
);
registerEncryptor(
"nip44",
(plaintext) => suite.v2.encrypt(plaintext),
"session-nip44_v2",
);
}
if (suite?.legacy?.encrypt) {
registerEncryptor(
"nip44",
(plaintext) => suite.legacy.encrypt(plaintext),
"session-nip44",
);
}
if (tools?.nip04 && typeof tools.nip04.encrypt === "function") {
registerEncryptor(
"nip04",
(plaintext) => tools.nip04.encrypt(privateKey, actorPubkey, plaintext),
"session-nip04",
);
}
};
await ensureSessionEncryptors();
if (!encryptionCandidates.length) {
userLogger.warn(
"[nostr] No encryption strategies available to publish watch history.",
{
actor: actorKey,
snapshotId,
},
);
return { ok: false, error: "encryption-unavailable", retryable: false };
}
const candidateIdentity = (candidate) =>
`${candidate.scheme}:${candidate.source || "unknown"}`;
const failedEncryptionCandidates = new Set();
const encryptionAttemptErrors = [];
let selectedEncryptor = null;
const tryEncryptWithCandidate = async (candidate, plaintext, context = {}) => {
const details = {
actor: actorKey,
snapshotId,
chunkIndex: context.chunkIndex ?? null,
chunkIdentifier: context.chunkIdentifier ?? null,
scheme: candidate.scheme,
source: candidate.source,
};
try {
devLogger.info("[nostr] Attempting to encrypt watch history chunk.", details);
const encrypted = await candidate.handler(plaintext);
if (typeof encrypted !== "string" || !encrypted) {
throw new Error("empty-ciphertext");
}
devLogger.info("[nostr] Encrypted watch history chunk.", details);
return { ciphertext: encrypted, scheme: candidate.scheme, candidate };
} catch (error) {
devLogger.warn("[nostr] Failed to encrypt watch history chunk with candidate.", {
...details,
error: error?.message || error,
});
failedEncryptionCandidates.add(candidateIdentity(candidate));
encryptionAttemptErrors.push({
scheme: candidate.scheme,
source: candidate.source,
error,
context: {
snapshotId,
chunkIndex: context.chunkIndex ?? null,
chunkIdentifier: context.chunkIdentifier ?? null,
},
});
return null;
}
};
const encryptChunkPayload = async (plaintext, context = {}) => {
if (
selectedEncryptor &&
!failedEncryptionCandidates.has(candidateIdentity(selectedEncryptor))
) {
const reuseResult = await tryEncryptWithCandidate(
selectedEncryptor,
plaintext,
context,
);
if (reuseResult) {
return reuseResult;
}
selectedEncryptor = null;
}
for (const candidate of encryptionCandidates) {
const identity = candidateIdentity(candidate);
if (failedEncryptionCandidates.has(identity)) {
continue;
}
const result = await tryEncryptWithCandidate(candidate, plaintext, context);
if (result) {
selectedEncryptor = candidate;
return result;
}
}
throw new Error("encryption-unavailable");
};
const signEvent = async (event) => {
if (activeSigner) {
@@ -1366,14 +1703,41 @@ class WatchHistoryManager {
totalChunks: chunks.length,
items: chunkItems,
});
const chunkIdentifier = `${snapshotId}:${index}`;
let ciphertext = "";
let encryptionScheme = "";
try {
ciphertext = await encryptChunk(plaintext);
const encryptionResult = await encryptChunkPayload(plaintext, {
chunkIndex: index,
chunkIdentifier,
});
ciphertext = encryptionResult?.ciphertext || "";
encryptionScheme = encryptionResult?.scheme || "";
} catch (error) {
userLogger.warn("[nostr] Failed to encrypt watch history chunk:", error);
userLogger.warn("[nostr] Failed to encrypt watch history chunk:", {
actor: actorKey,
snapshotId,
chunkIndex: index,
chunkIdentifier,
error: error?.message || error,
attempts: encryptionAttemptErrors.map((entry) => ({
scheme: entry.scheme,
source: entry.source,
error: entry.error?.message || entry.error,
})),
});
return { ok: false, error: "encryption-failed", retryable: false };
}
if (!ciphertext) {
userLogger.warn("[nostr] Encryption produced an empty payload for watch history chunk.", {
actor: actorKey,
snapshotId,
chunkIndex: index,
chunkIdentifier,
scheme: encryptionScheme || null,
});
return { ok: false, error: "encryption-failed", retryable: false };
}
const chunkIdentifier = `${snapshotId}:${index}`;
const event = buildWatchHistoryChunkEvent({
pubkey: actorPubkey,
created_at: createdAtCursor,
@@ -1383,6 +1747,7 @@ class WatchHistoryManager {
totalChunks: chunks.length,
pointerTags,
content: ciphertext,
encryption: encryptionScheme,
});
createdAtCursor += 1;
let signedEvent;
@@ -1735,7 +2100,7 @@ class WatchHistoryManager {
normalizedLogged &&
normalizedLogged === actorKey &&
signer &&
typeof signer.nip04Decrypt === "function";
(typeof signer.nip04Decrypt === "function" || typeof signer.nip44Decrypt === "function");
const decryptSigner = canUseActiveSignerDecrypt ? signer : null;
if (decryptSigner && this.deps.shouldRequestExtensionPermissions?.(decryptSigner)) {
await this.deps.ensureExtensionPermissions?.(DEFAULT_NIP07_PERMISSION_METHODS);
@@ -1976,79 +2341,95 @@ class WatchHistoryManager {
}
const decryptErrors = [];
const collectedItems = [];
const ensureDecryptTools = async () => {
const tools = await ensureNostrTools();
if (tools?.nip04 && typeof tools.nip04.decrypt === "function") {
devLogger.info("[nostr] Loaded nostr-tools nip04 helpers for watch history decryption.");
return tools;
const toolkitCache = { current: null };
const decryptorCandidates = new Map();
const registerDecryptor = (scheme, handler, source) => {
if (!scheme || typeof handler !== "function") {
return;
}
userLogger.warn(
"[nostr] Unable to load nostr-tools nip04 helpers for watch history decryption.",
);
return null;
const existing = decryptorCandidates.get(scheme) || [];
existing.push({ handler, source: source || "unknown" });
decryptorCandidates.set(scheme, existing);
};
const decryptChunk = async (ciphertext, context = {}) => {
if (decryptSigner) {
devLogger.info("[nostr] Using active signer to decrypt watch history chunk.", {
actorKey,
chunkIdentifier: context.chunkIdentifier ?? null,
eventId: context.eventId ?? null,
});
const plaintext = await decryptSigner.nip04Decrypt(actorKey, ciphertext);
devLogger.info("[nostr] Successfully decrypted watch history chunk via active signer.", {
actorKey,
chunkIdentifier: context.chunkIdentifier ?? null,
eventId: context.eventId ?? null,
});
return plaintext;
}
const session = this.deps.getSessionActor?.();
if (!session || session.pubkey !== actorKey) {
devLogger.info(
"[nostr] Session actor mismatch while decrypting watch history chunk. Ensuring session actor matches requested key.",
if (decryptSigner) {
if (typeof decryptSigner.nip44Decrypt === "function") {
registerDecryptor(
"nip44_v2",
(payload) => decryptSigner.nip44Decrypt(actorKey, payload),
"active-signer",
);
const ensured = await this.deps.ensureSessionActor?.();
if (normalizeActorKey(ensured) !== actorKey) {
throw new Error("session-actor-mismatch");
registerDecryptor(
"nip44",
(payload) => decryptSigner.nip44Decrypt(actorKey, payload),
"active-signer",
);
}
if (typeof decryptSigner.nip04Decrypt === "function") {
registerDecryptor(
"nip04",
(payload) => decryptSigner.nip04Decrypt(actorKey, payload),
"active-signer",
);
}
}
const ensureSessionDecryptors = async () => {
try {
let session = this.deps.getSessionActor?.();
if (!session || session.pubkey !== actorKey || !session.privateKey) {
const ensured = await this.deps.ensureSessionActor?.();
if (normalizeActorKey(ensured) !== actorKey) {
throw new Error("session-actor-mismatch");
}
session = this.deps.getSessionActor?.();
}
}
const refreshedSession = this.deps.getSessionActor?.();
if (!refreshedSession || refreshedSession.pubkey !== actorKey) {
throw new Error("session-actor-missing");
}
const tools = await ensureDecryptTools();
if (!tools?.nip04 || typeof tools.nip04.decrypt !== "function") {
userLogger.warn(
"[nostr] Unable to decrypt watch history chunk because nip04 helpers are unavailable.",
{
actorKey,
chunkIdentifier: context.chunkIdentifier ?? null,
eventId: context.eventId ?? null,
},
);
throw new Error("nip04-unavailable");
}
devLogger.info(
"[nostr] Using session actor private key to decrypt watch history chunk.",
{
if (!session || session.pubkey !== actorKey) {
throw new Error("session-actor-missing");
}
if (!session.privateKey) {
throw new Error("missing-session-key");
}
const tools = await resolveNostrToolkit(this.deps, toolkitCache);
if (!tools) {
throw new Error("nostr-tools-missing");
}
const suite = createNip44CipherSuite(tools, session.privateKey, actorKey);
if (suite?.v2?.decrypt) {
registerDecryptor(
"nip44_v2",
async (payload) => suite.v2.decrypt(payload),
"session-nip44_v2",
);
registerDecryptor(
"nip44",
async (payload) => suite.v2.decrypt(payload),
"session-nip44_v2",
);
}
if (suite?.legacy?.decrypt) {
registerDecryptor(
"nip44",
async (payload) => suite.legacy.decrypt(payload),
"session-nip44",
);
}
if (tools?.nip04 && typeof tools.nip04.decrypt === "function") {
registerDecryptor(
"nip04",
async (payload) => tools.nip04.decrypt(session.privateKey, actorKey, payload),
"session-nip04",
);
}
} catch (error) {
devLogger.warn("[nostr] Unable to register session decryptors for watch history.", {
actorKey,
chunkIdentifier: context.chunkIdentifier ?? null,
eventId: context.eventId ?? null,
sessionActor: refreshedSession.pubkey ?? null,
},
);
const plaintext = await tools.nip04.decrypt(
refreshedSession.privateKey,
actorKey,
ciphertext,
);
devLogger.info("[nostr] Successfully decrypted watch history chunk via session actor key.", {
actorKey,
chunkIdentifier: context.chunkIdentifier ?? null,
eventId: context.eventId ?? null,
});
return plaintext;
error: error?.message || error,
});
}
};
await ensureSessionDecryptors();
const chunkCount = latestChunks.size || chunkIdentifiers.length || 0;
const chunkKeys = chunkIdentifiers.length
? chunkIdentifiers
@@ -2061,65 +2442,119 @@ class WatchHistoryManager {
const fallbackPointers = extractPointerItemsFromEvent(event);
const ciphertext = typeof event.content === "string" ? event.content : "";
const ciphertextPreview = ciphertext.slice(0, 32);
let payload;
const chunkContext = {
chunkIdentifier: identifier,
eventId: event.id ?? null,
};
if (isNip04EncryptedWatchHistoryEvent(event, ciphertext)) {
devLogger.info("[nostr] Watch history chunk is marked as NIP-04 encrypted. Beginning decrypt flow.", {
actorKey,
...chunkContext,
});
try {
const plaintext = await decryptChunk(ciphertext, chunkContext);
devLogger.info("[nostr] Decrypted watch history chunk. Parsing plaintext payload.", {
actorKey,
...chunkContext,
plaintextPreview: typeof plaintext === "string" ? plaintext.slice(0, 64) : null,
expectedPlaintextFormat:
"JSON string with { version, items, snapshot, chunkIndex, totalChunks }",
});
payload = parseWatchHistoryContentWithFallback(
plaintext,
fallbackPointers,
{
version: 0,
items: fallbackPointers,
snapshot: snapshotId,
chunkIndex: 0,
totalChunks: chunkCount || 1,
},
);
} catch (error) {
decryptErrors.push(error);
userLogger.error(
"[nostr] Decrypt failed for watch history chunk. Falling back to pointer items.",
{
const availableSchemes = Array.from(decryptorCandidates.keys());
const decryptOrder = determineWatchHistoryDecryptionOrder(
event,
ciphertext,
availableSchemes,
);
let plaintext = null;
let usedScheme = "";
let usedSource = "";
const attemptFailures = [];
if (decryptOrder.length) {
outer: for (const scheme of decryptOrder) {
const candidates = decryptorCandidates.get(scheme) || [];
for (const candidate of candidates) {
const details = {
actorKey,
...chunkContext,
error: error?.message || error,
ciphertextPreview,
fallbackPointerCount: Array.isArray(fallbackPointers)
? fallbackPointers.length
: 0,
expectedPlaintextFormat:
"JSON string with { version, items, snapshot, chunkIndex, totalChunks }",
},
);
payload = {
version: 0,
items: fallbackPointers,
};
scheme,
source: candidate.source,
};
try {
devLogger.info("[nostr] Attempting to decrypt watch history chunk.", details);
const result = await candidate.handler(ciphertext, chunkContext);
if (typeof result !== "string") {
throw new Error("invalid-plaintext");
}
devLogger.info("[nostr] Successfully decrypted watch history chunk.", details);
plaintext = result;
usedScheme = scheme;
usedSource = candidate.source || "";
break outer;
} catch (error) {
devLogger.warn("[nostr] Failed to decrypt watch history chunk with candidate.", {
...details,
error: error?.message || error,
});
attemptFailures.push({ scheme, source: candidate.source, error });
}
}
}
} else {
devLogger.info("[nostr] Watch history chunk is plaintext. Attempting to parse expected payload format.", {
}
const encryptionHints = extractWatchHistoryEncryptionHints(event, ciphertext);
const isEncryptedChunk =
encryptionHints.length > 0 ||
(!looksLikeJsonStructure(ciphertext) && Boolean(ciphertext.trim()));
let payload;
if (plaintext !== null) {
devLogger.info("[nostr] Decrypted watch history chunk. Parsing plaintext payload.", {
actorKey,
...chunkContext,
ciphertextPreview,
scheme: usedScheme || null,
source: usedSource || null,
plaintextPreview: typeof plaintext === "string" ? plaintext.slice(0, 64) : null,
expectedPlaintextFormat:
"JSON string with { version, items, snapshot, chunkIndex, totalChunks }",
});
payload = parseWatchHistoryContentWithFallback(
plaintext,
fallbackPointers,
{
version: 0,
items: fallbackPointers,
snapshot: snapshotId,
chunkIndex: 0,
totalChunks: chunkCount || 1,
},
);
} else if (isEncryptedChunk) {
decryptErrors.push({
chunkIdentifier: identifier,
eventId: event.id ?? null,
attempts: attemptFailures,
});
userLogger.error(
"[nostr] Decrypt failed for watch history chunk. Falling back to pointer items.",
{
actorKey,
...chunkContext,
attempts: attemptFailures.map((entry) => ({
scheme: entry.scheme,
source: entry.source,
error: entry.error?.message || entry.error,
})),
ciphertextPreview,
fallbackPointerCount: Array.isArray(fallbackPointers)
? fallbackPointers.length
: 0,
expectedPlaintextFormat:
"JSON string with { version, items, snapshot, chunkIndex, totalChunks }",
},
);
payload = {
version: 0,
items: fallbackPointers,
};
} else {
devLogger.info(
"[nostr] Watch history chunk is plaintext. Attempting to parse expected payload format.",
{
actorKey,
...chunkContext,
ciphertextPreview,
expectedPlaintextFormat:
"JSON string with { version, items, snapshot, chunkIndex, totalChunks }",
},
);
payload = parseWatchHistoryContentWithFallback(
ciphertext,
fallbackPointers,
@@ -2132,6 +2567,7 @@ class WatchHistoryManager {
},
);
}
if (Array.isArray(payload?.items)) {
collectedItems.push(...payload.items);
}
+20 -4
View File
@@ -280,14 +280,14 @@ const BASE_SCHEMAS = {
identifierTag: {
name: "d",
},
encryptionTag: { name: "encrypted", value: "nip04" },
encryptionTag: { name: "encrypted", values: ["nip44_v2", "nip44", "nip04"] },
snapshotTagName: "snapshot",
chunkTagName: "chunk",
headTag: { name: "head", value: "1" },
headTagIndex: 2,
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "nip04-json",
format: "encrypted-json",
description:
"Encrypted JSON payload containing chunked watch history entries.",
},
@@ -1309,6 +1309,7 @@ export function buildWatchHistoryChunkEvent({
pointerTags = [],
chunkAddresses = [],
content,
encryption,
}) {
const schema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_CHUNK);
const tags = [];
@@ -1317,8 +1318,23 @@ export function buildWatchHistoryChunkEvent({
if (identifierName && identifierValue) {
tags.push([identifierName, identifierValue]);
}
if (schema?.encryptionTag?.name && schema?.encryptionTag?.value) {
tags.push([schema.encryptionTag.name, schema.encryptionTag.value]);
const encryptionTagName = schema?.encryptionTag?.name;
const normalizedOptions = Array.isArray(schema?.encryptionTag?.values)
? schema.encryptionTag.values
.map((value) => (typeof value === "string" ? value.trim() : ""))
.filter(Boolean)
: [];
const normalizedRequested = typeof encryption === "string" ? encryption.trim() : "";
let resolvedEncryptionTag = "";
if (normalizedRequested) {
resolvedEncryptionTag = normalizedRequested;
} else if (typeof schema?.encryptionTag?.value === "string") {
resolvedEncryptionTag = schema.encryptionTag.value;
} else if (normalizedOptions.length) {
[resolvedEncryptionTag] = normalizedOptions;
}
if (encryptionTagName && resolvedEncryptionTag) {
tags.push([encryptionTagName, resolvedEncryptionTag]);
}
const snapshotTagName = schema?.snapshotTagName;
if (snapshotTagName && snapshotId) {
+286 -81
View File
@@ -316,6 +316,32 @@ async function installSessionCrypto({ privateKey }) {
const { createHash, randomBytes } = await import("node:crypto");
const deriveHex = (input) =>
createHash("sha256").update(String(input ?? ""), "utf8").digest("hex");
const keyToHex = (value) => {
if (typeof value === "string") {
return value;
}
if (value instanceof Uint8Array || Array.isArray(value)) {
return Buffer.from(value).toString("hex");
}
if (value && typeof value === "object" && typeof value.length === "number") {
return Buffer.from(value).toString("hex");
}
return deriveHex(value);
};
const encodePayload = (prefix, conversationKey, plaintext) => {
const keySegment = keyToHex(conversationKey);
const payload = Buffer.from(plaintext, "utf8").toString("base64");
return `${prefix}:${keySegment}:${payload}`;
};
const decodePayload = (prefix, conversationKey, ciphertext) => {
const keySegment = keyToHex(conversationKey);
const expected = `${prefix}:${keySegment}:`;
if (!ciphertext.startsWith(expected)) {
throw new Error("invalid-session-ciphertext");
}
const encoded = ciphertext.slice(expected.length);
return Buffer.from(encoded, "base64").toString("utf8");
};
const previousEnsure =
nostrClient.watchHistory?.deps?.ensureNostrTools || null;
const previousGetCached =
@@ -350,6 +376,40 @@ async function installSessionCrypto({ privateKey }) {
return Buffer.from(encoded, "base64").toString("utf8");
},
},
nip44: {
...(original.nip44 || {}),
encrypt: (plaintext, conversationKey) => {
encryptCalls += 1;
return encodePayload("session44-legacy", conversationKey, plaintext);
},
decrypt: (ciphertext, conversationKey) => {
decryptCalls += 1;
return decodePayload("session44-legacy", conversationKey, ciphertext);
},
getConversationKey: (privBytes, target) =>
`${keyToHex(privBytes)}:${target}:legacy`,
utils: {
...(original.nip44?.utils || {}),
getConversationKey: (privBytes, target) =>
`${keyToHex(privBytes)}:${target}:legacy-utils`,
},
v2: {
...(original.nip44?.v2 || {}),
encrypt: (plaintext, conversationKey) => {
encryptCalls += 1;
return encodePayload("session44", conversationKey, plaintext);
},
decrypt: (ciphertext, conversationKey) => {
decryptCalls += 1;
return decodePayload("session44", conversationKey, ciphertext);
},
utils: {
...(original.nip44?.v2?.utils || {}),
getConversationKey: (privBytes, target) =>
`${keyToHex(privBytes)}:${target}:v2`,
},
},
},
};
rememberNostrTools(window.NostrTools);
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = window.NostrTools;
@@ -376,10 +436,11 @@ async function installSessionCrypto({ privateKey }) {
},
getEncryptCalls: () => encryptCalls,
getDecryptCalls: () => decryptCalls,
getPrivateKey: () => privateKey,
};
}
function installExtensionCrypto({ actor }) {
function installExtensionCrypto({ actor, supportsNip44 = true }) {
const originalNostr = window.nostr;
const originalTools = window.NostrTools || {};
let extensionEncrypts = 0;
@@ -387,6 +448,18 @@ function installExtensionCrypto({ actor }) {
let fallbackEncrypts = 0;
const enableCalls = [];
const decryptCalls = [];
const encodePayload = (prefix, target, plaintext) => {
const payload = Buffer.from(plaintext, "utf8").toString("base64");
return `${prefix}:${target}:${payload}`;
};
const decodePayload = (prefix, target, ciphertext) => {
const expected = `${prefix}:${target}:`;
if (!ciphertext.startsWith(expected)) {
throw new Error("invalid-extension-ciphertext");
}
const encoded = ciphertext.slice(expected.length);
return Buffer.from(encoded, "base64").toString("utf8");
};
window.nostr = {
enable: async (options) => {
enableCalls.push(options || null);
@@ -400,21 +473,28 @@ function installExtensionCrypto({ actor }) {
nip04: {
encrypt: async (target, plaintext) => {
extensionEncrypts += 1;
const payload = Buffer.from(plaintext, "utf8").toString("base64");
return `extension:${target}:${payload}`;
return encodePayload("extension", target, plaintext);
},
decrypt: async (target, ciphertext) => {
extensionDecrypts += 1;
decryptCalls.push({ target, ciphertext });
const prefix = `extension:${target}:`;
if (!ciphertext.startsWith(prefix)) {
throw new Error("invalid-extension-ciphertext");
}
const encoded = ciphertext.slice(prefix.length);
return Buffer.from(encoded, "base64").toString("utf8");
decryptCalls.push({ scheme: "nip04", target, ciphertext });
return decodePayload("extension", target, ciphertext);
},
},
};
if (supportsNip44) {
window.nostr.nip44 = {
encrypt: async (target, plaintext) => {
extensionEncrypts += 1;
return encodePayload("extension44", target, plaintext);
},
decrypt: async (target, ciphertext) => {
extensionDecrypts += 1;
decryptCalls.push({ scheme: "nip44", target, ciphertext });
return decodePayload("extension44", target, ciphertext);
},
};
}
window.NostrTools = {
...originalTools,
nip04: {
@@ -423,27 +503,24 @@ function installExtensionCrypto({ actor }) {
fallbackEncrypts += 1;
throw new Error("fallback-encrypt-used");
},
decrypt: async (secret, pub, ciphertext) => {
const prefix = `session:${secret}:${pub}:`;
if (!ciphertext.startsWith(prefix)) {
throw new Error("invalid-session-ciphertext");
}
const encoded = ciphertext.slice(prefix.length);
return Buffer.from(encoded, "base64").toString("utf8");
},
},
};
rememberNostrTools(window.NostrTools);
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = window.NostrTools;
globalThis.nostrToolsReady = Promise.resolve(window.NostrTools);
const previousSigner = getActiveSigner();
setActiveSigner({
const signer = {
type: "extension",
pubkey: actor,
signEvent: window.nostr.signEvent,
nip04Encrypt: window.nostr.nip04.encrypt,
nip04Decrypt: window.nostr.nip04.decrypt,
});
};
if (supportsNip44) {
signer.nip44Encrypt = window.nostr.nip44.encrypt;
signer.nip44Decrypt = window.nostr.nip44.decrypt;
}
setActiveSigner(signer);
return {
restore() {
if (previousSigner) {
@@ -611,49 +688,19 @@ async function testFetchWatchHistoryExtensionDecryptsHexAndNpub() {
const fallbackTag = ["e", "fallback-pointer"];
const fallbackItem = { type: "e", value: "fallback-pointer", watchedAt: 1_700_800_000 };
const chunkItem = { type: "e", value: "chunk-pointer", watchedAt: 1_700_800_100 };
const chunkLegacyItem = {
type: "e",
value: "legacy-pointer",
watchedAt: 1_700_800_120,
};
const chunkPayload = JSON.stringify({
version: 1,
version: 2,
items: [chunkItem],
snapshot: snapshotId,
chunkIndex: 0,
totalChunks: 1,
totalChunks: 2,
});
const chunkCiphertext = `extension:${actorHex}:${Buffer.from(chunkPayload, "utf8").toString("base64")}`;
const pointerEvent = {
id: "pointer-extension",
kind: WATCH_HISTORY_KIND,
pubkey: actorHex,
created_at: 1_700_800_000,
content: JSON.stringify({
version: 1,
items: [fallbackItem],
snapshot: snapshotId,
chunkIndex: 0,
totalChunks: 1,
}),
tags: [
["d", WATCH_HISTORY_LIST_IDENTIFIER],
["snapshot", snapshotId],
["encrypted", "nip04"],
["a", `${WATCH_HISTORY_KIND}:${actorHex}:${chunkIdentifier}`],
fallbackTag,
],
};
const chunkEvent = {
id: "chunk-extension",
kind: WATCH_HISTORY_KIND,
pubkey: actorHex,
created_at: 1_700_800_060,
content: chunkCiphertext,
tags: [
["d", chunkIdentifier],
["snapshot", snapshotId],
["encrypted", "nip04"],
],
};
if (!window.NostrTools || typeof window.NostrTools !== "object") {
window.NostrTools = {};
@@ -686,19 +733,84 @@ async function testFetchWatchHistoryExtensionDecryptsHexAndNpub() {
nostrClient.watchHistoryCache = new Map();
nostrClient.watchHistoryStorage = { version: 2, actors: {} };
const publishEvents = async () => {
const runVariant = async (label, actorInput, pubkeyInput) => {
const sessionCrypto = await installSessionCrypto({ privateKey: `session-${label}` });
const sessionTools = window.NostrTools;
const legacyPayload = JSON.stringify({
version: 2,
items: [chunkLegacyItem],
snapshot: snapshotId,
chunkIndex: 1,
totalChunks: 2,
});
const legacyCiphertext = await sessionTools.nip04.encrypt(
sessionCrypto.getPrivateKey(),
actorHex,
legacyPayload,
);
const extensionCrypto = installExtensionCrypto({ actor: actorHex });
poolHarness.reset();
poolHarness.setResolver(() => ({ ok: true }));
const nip44Ciphertext = await window.nostr.nip44.encrypt(actorHex, chunkPayload);
const pointerEvent = {
id: `pointer-${label}`,
kind: WATCH_HISTORY_KIND,
pubkey: actorHex,
created_at: 1_700_800_000,
content: JSON.stringify({
version: 2,
items: [fallbackItem],
snapshot: snapshotId,
chunkIndex: 0,
totalChunks: 2,
}),
tags: [
["d", WATCH_HISTORY_LIST_IDENTIFIER],
["snapshot", snapshotId],
["encrypted", "nip44_v2"],
["a", `${WATCH_HISTORY_KIND}:${actorHex}:${chunkIdentifier}`],
["a", `${WATCH_HISTORY_KIND}:${actorHex}:${chunkIdentifier}-legacy`],
fallbackTag,
],
};
const chunkEvent = {
id: `chunk-extension-${label}`,
kind: WATCH_HISTORY_KIND,
pubkey: actorHex,
created_at: 1_700_800_060,
content: nip44Ciphertext,
tags: [
["d", chunkIdentifier],
["snapshot", snapshotId],
["chunk", "0", "2"],
["encrypted", "nip44_v2"],
],
};
const chunkLegacyEvent = {
id: `chunk-legacy-${label}`,
kind: WATCH_HISTORY_KIND,
pubkey: actorHex,
created_at: 1_700_800_120,
content: legacyCiphertext,
tags: [
["d", `${chunkIdentifier}-legacy`],
["snapshot", snapshotId],
["chunk", "1", "2"],
["encrypted", "nip04"],
],
};
poolHarness.publish(["wss://relay.test"], pointerEvent);
poolHarness.publish(["wss://relay.test"], chunkEvent);
poolHarness.publish(["wss://relay.test"], chunkLegacyEvent);
await new Promise((resolve) => setTimeout(resolve, 0));
};
const runVariant = async (label, actorInput, pubkeyInput) => {
await publishEvents();
const sessionCrypto = await installSessionCrypto({ privateKey: `session-${label}` });
const extensionCrypto = installExtensionCrypto({ actor: actorHex });
const previousEnsure = nostrClient.ensureSessionActor;
const previousSession = nostrClient.sessionActor;
@@ -707,10 +819,11 @@ async function testFetchWatchHistoryExtensionDecryptsHexAndNpub() {
try {
nostrClient.pubkey = pubkeyInput;
nostrClient.sessionActor = null;
nostrClient.ensureSessionActor = async () => {
throw new Error(`session fallback should not run for ${label}`);
nostrClient.sessionActor = {
pubkey: actorHex,
privateKey: sessionCrypto.getPrivateKey(),
};
nostrClient.ensureSessionActor = async () => actorHex;
nostrClient.watchHistoryCache.clear();
nostrClient.watchHistoryStorage = { version: 2, actors: {} };
@@ -720,31 +833,46 @@ async function testFetchWatchHistoryExtensionDecryptsHexAndNpub() {
assert.equal(
extensionCrypto.getExtensionDecrypts(),
1,
`${label} actor should use extension decrypt`,
2,
`${label} actor should attempt extension decrypt for each encrypted chunk`,
);
assert.equal(
sessionCrypto.getDecryptCalls(),
0,
`${label} actor should not trigger session decrypt fallback`,
1,
`${label} actor should invoke session decrypt for nip04 fallback chunks`,
);
const decryptCalls = extensionCrypto.getDecryptCalls();
assert.equal(
decryptCalls.length,
1,
`${label} actor should invoke extension decrypt exactly once`,
2,
`${label} actor should attempt extension decrypt for both chunk schemes`,
);
assert.equal(
decryptCalls[0]?.target,
actorHex,
`${label} actor should normalize npub inputs to hex for decrypt`,
);
assert.equal(result.items.length, 1, `${label} actor should return chunk item`);
assert.equal(
result.items[0]?.value,
chunkItem.value,
`${label} actor should surface decrypted pointer value`,
decryptCalls[0]?.scheme,
"nip44",
`${label} actor should use nip44 for the first chunk`,
);
assert.equal(
decryptCalls[1]?.scheme,
"nip04",
`${label} actor should attempt nip04 via the extension before falling back`,
);
assert.equal(
result.items.length,
2,
`${label} actor should merge decrypted items from both chunks`,
);
const values = result.items.map((item) => item?.value);
assert(values.includes(chunkItem.value), `${label} actor should include nip44 chunk item`);
assert(
values.includes(chunkLegacyItem.value),
`${label} actor should include nip04 chunk item`,
);
} finally {
extensionCrypto.restore();
@@ -973,14 +1101,23 @@ async function testPublishSnapshotUsesExtensionCrypto() {
);
assert(extension.getExtensionEncrypts() > 0, "extension encrypt should be invoked");
const decrypt = window.nostr?.nip04?.decrypt;
const chunkEvent = result.chunkEvents[0];
const schemeTag = Array.isArray(chunkEvent?.tags)
? chunkEvent.tags.find((tag) => Array.isArray(tag) && tag[0] === "encrypted")
: null;
const encryptionScheme = typeof schemeTag?.[1] === "string" ? schemeTag[1] : "";
assert.equal(encryptionScheme, "nip44_v2", "chunk should advertise nip44_v2 encryption");
const decrypt =
encryptionScheme === "nip44_v2" || encryptionScheme === "nip44"
? window.nostr?.nip44?.decrypt
: window.nostr?.nip04?.decrypt;
assert.equal(
typeof decrypt,
"function",
"extension decrypt helper should be available",
);
const decrypted = await decrypt(actor, result.chunkEvents[0].content);
const decrypted = await decrypt(actor, chunkEvent.content);
const payload = JSON.parse(decrypted);
assert.equal(payload.snapshot, "extension");
assert.equal(
@@ -988,6 +1125,12 @@ async function testPublishSnapshotUsesExtensionCrypto() {
result.items.length,
"decrypted payload should match canonical item count",
);
const decryptCalls = extension.getDecryptCalls();
assert.equal(
decryptCalls[0]?.scheme,
"nip44",
"extension decrypt should prefer nip44 before falling back",
);
assert.equal(
extension.getEnableCalls().length,
1,
@@ -1002,6 +1145,67 @@ async function testPublishSnapshotUsesExtensionCrypto() {
}
}
async function testPublishSnapshotFallsBackToNip04WhenNip44Unavailable() {
poolHarness.reset();
poolHarness.setResolver(() => ({ ok: true }));
const actor = "ext-fallback-pubkey";
const sessionRestore = await installSessionCrypto({ privateKey: "fallback-priv" });
const extension = installExtensionCrypto({ actor, supportsNip44: false });
const originalEnsure = nostrClient.ensureSessionActor;
const originalSession = nostrClient.sessionActor;
const originalPub = nostrClient.pubkey;
try {
nostrClient.pubkey = actor;
nostrClient.sessionActor = null;
nostrClient.ensureSessionActor = async () => actor;
const result = await nostrClient.publishWatchHistorySnapshot(
[
{ type: "e", value: "fallback-pointer-1", watchedAt: 90 },
{ type: "e", value: "fallback-pointer-2", watchedAt: 60 },
],
{ actorPubkey: actor, snapshotId: "extension-nip04" },
);
assert.ok(result.ok, "snapshot should succeed when extension lacks nip44");
const chunkEvent = result.chunkEvents[0];
const schemeTag = Array.isArray(chunkEvent?.tags)
? chunkEvent.tags.find((tag) => Array.isArray(tag) && tag[0] === "encrypted")
: null;
assert.equal(
schemeTag?.[1],
"nip04",
"chunk should advertise nip04 when nip44 is unavailable",
);
const decrypt = window.nostr?.nip04?.decrypt;
assert.equal(typeof decrypt, "function", "nip04 decrypt helper should be available");
const decrypted = await decrypt(actor, chunkEvent.content);
const payload = JSON.parse(decrypted);
assert.equal(payload.snapshot, "extension-nip04");
const decryptCalls = extension.getDecryptCalls();
assert.equal(decryptCalls.length, 1, "nip04 fallback should invoke a single decrypt attempt");
assert.equal(
decryptCalls[0]?.scheme,
"nip04",
"extension decrypt should use nip04 when nip44 is unavailable",
);
assert.equal(
extension.getExtensionDecrypts(),
1,
"extension nip04 decrypt should be invoked exactly once",
);
} finally {
extension.restore();
sessionRestore.restore();
nostrClient.ensureSessionActor = originalEnsure;
nostrClient.sessionActor = originalSession;
nostrClient.pubkey = originalPub;
}
}
async function testEnsureExtensionPermissionCaching() {
const actor = "permission-actor";
const extension = installExtensionCrypto({ actor });
@@ -2453,6 +2657,7 @@ async function testWatchHistoryAppLoginFallback() {
await testPublishSnapshotCanonicalizationAndChunking();
await testPublishSnapshotUsesExtensionCrypto();
await testPublishSnapshotFallsBackToNip04WhenNip44Unavailable();
await testEnsureExtensionPermissionCaching();
await testFetchWatchHistoryExtensionDecryptsHexAndNpub();
await testPublishSnapshotFailureRetry();