mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-30 01:12:39 +00:00
Handle dual NIP-44/NIP-04 watch history encryption
This commit is contained in:
1 parent
4cba776c7c
commit
cd3c3824b9
4 files changed
+930
-270
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user