diff --git a/README.md b/README.md index c611b803..6c56615f 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ To run **bitvid** locally: npx serve ``` -3. Open the site in your browser: +4. Open the site in your browser: ``` http://localhost:8000 @@ -136,7 +136,7 @@ http://localhost:8000 Use the event builders in `js/nostrEventSchemas.js` to construct valid video notes. ```javascript -import { buildVideoPostEvent } from './js/nostrEventSchemas.js'; +import { buildVideoPostEvent } from "./js/nostrEventSchemas.js"; const event = buildVideoPostEvent({ pubkey: "your_pubkey_hex", @@ -147,7 +147,7 @@ const event = buildVideoPostEvent({ title: "My First Video", videoRootId: "my-first-video", // Logical ID, often matches d-tag url: "https://example.com/video.mp4", - description: "This is a test video post sent via the SDK.", + description: "This is a test video post sent via the SDK." // magnet: "magnet:?xt=urn:btih:..." // Optional fallback } }); @@ -259,7 +259,10 @@ signer, fall back to supported capabilities, and surface permission prompts. Use the facade for the common, stable entry points: ```js -import { nostrClient, requestDefaultExtensionPermissions } from "./nostrClientFacade.js"; +import { + nostrClient, + requestDefaultExtensionPermissions +} from "./nostrClientFacade.js"; ``` Custom auth providers should still register their signer object through the @@ -378,7 +381,7 @@ placeholder at “—” and development builds log a warning—so mixed deploym 5. **Test**: - Run unit tests: `npm run test:unit` - Validate the site functionality locally before submitting. -5. **Submit a Pull Request**: +6. **Submit a Pull Request**: - Explain your changes and reference any related issues. ### Contribution Guidelines diff --git a/js/healthService.js b/js/healthService.js deleted file mode 100644 index bab24b41..00000000 --- a/js/healthService.js +++ /dev/null @@ -1,117 +0,0 @@ -import PQueue from "https://esm.sh/p-queue@7.4.1"; -import { trackerPing } from "./trackerPing.js"; -import { infoHashFromMagnet } from "./magnets.js"; -import { HEALTH_TTL_MS, CONCURRENCY } from "./trackerConfig.js"; -import { userLogger } from "./utils/logger.js"; -import { - readTrackerHealthFromStorage, - removeTrackerHealthFromStorage, - writeTrackerHealthToStorage, -} from "./utils/storage.js"; - -const queue = new PQueue({ concurrency: CONCURRENCY }); -const cache = new Map(); -const inflight = new Map(); - -export function getDefaultHealth() { - return { - ok: false, - seeders: 0, - leechers: 0, - responded: false, - from: [], - }; -} - -export function getHealthCached(infoHash) { - if (!infoHash) { - return null; - } - let entry = cache.get(infoHash); - - if (!entry) { - entry = readTrackerHealthFromStorage(infoHash); - } - - if (!entry) { - return null; - } - if (Date.now() - entry.ts > HEALTH_TTL_MS) { - cache.delete(infoHash); - removeTrackerHealthFromStorage(infoHash); - return null; - } - - if (!cache.has(infoHash)) { - cache.set(infoHash, entry); - } - - return entry.value; -} - -export function setHealthCache(infoHash, value) { - if (!infoHash) { - return; - } - const entry = { ts: Date.now(), value }; - cache.set(infoHash, entry); - writeTrackerHealthToStorage(infoHash, entry); -} - -export function queueHealthCheck(magnet, onResult) { - const infoHash = infoHashFromMagnet(magnet); - if (!infoHash) { - const fallback = getDefaultHealth(); - if (typeof onResult === "function") { - onResult(fallback); - } - return Promise.resolve(fallback); - } - - const cached = getHealthCached(infoHash); - if (cached) { - if (typeof onResult === "function") { - onResult(cached); - } - return Promise.resolve(cached); - } - - if (inflight.has(infoHash)) { - const pending = inflight.get(infoHash); - if (typeof onResult === "function") { - pending.then(onResult).catch(() => {}); - } - return pending; - } - - const jobPromise = queue - .add(async () => { - try { - const health = await trackerPing(magnet); - setHealthCache(infoHash, health); - return health; - } catch (err) { - userLogger.warn("trackerPing failed", err); - return getDefaultHealth(); - } - }) - .finally(() => { - inflight.delete(infoHash); - }); - - inflight.set(infoHash, jobPromise); - if (typeof onResult === "function") { - jobPromise.then(onResult).catch(() => {}); - } - return jobPromise; -} - -export function purgeHealthCache() { - const now = Date.now(); - Array.from(cache.keys()).forEach((infoHash) => { - const entry = cache.get(infoHash); - if (!entry || now - entry.ts > HEALTH_TTL_MS) { - cache.delete(infoHash); - } - }); -} diff --git a/js/nostr/signer-interface.js b/js/nostr/signer-interface.js deleted file mode 100644 index 57093cc2..00000000 --- a/js/nostr/signer-interface.js +++ /dev/null @@ -1,50 +0,0 @@ -// js/nostr/signer-interface.js - -/** - * Canonical async signer interface used by bitvid. - * - * @typedef {Object} NostrSignerInterface - * @property {string} type - * Identifier for the signer implementation ("nsec", "nip07", "nip46", etc.). - * @property {string} pubkey - * Hex-encoded public key for the signer (lowercase). - * @property {() => Promise} metadata - * Returns signer metadata (name, url, image, etc.) when available. - * @property {() => Promise} relays - * Returns relay hints or preferences when available. - * @property {(event: object) => Promise} signEvent - * Signs a Nostr event payload and returns the signed event. - * @property {(targetPubkey: string, plaintext: string) => Promise} [nip04Encrypt] - * Optional NIP-04 encryption helper. - * @property {(targetPubkey: string, ciphertext: string) => Promise} [nip04Decrypt] - * Optional NIP-04 decryption helper. - * @property {(targetPubkey: string, plaintext: string) => Promise} [nip44Encrypt] - * Optional NIP-44 encryption helper. - * @property {(targetPubkey: string, ciphertext: string) => Promise} [nip44Decrypt] - * Optional NIP-44 decryption helper. - * @property {(methods?: string[]) => Promise} requestPermissions - * Requests signer permissions for the provided methods, when supported. - * @property {() => Promise} destroy - * Cleans up any resources associated with the signer. - * @property {() => boolean} canSign - * Returns true when signEvent is available and ready. - */ - -export const SIGNER_INTERFACE_FIELDS = Object.freeze({ - required: Object.freeze([ - "type", - "pubkey", - "metadata", - "relays", - "signEvent", - "requestPermissions", - "destroy", - "canSign", - ]), - optional: Object.freeze([ - "nip04Encrypt", - "nip04Decrypt", - "nip44Encrypt", - "nip44Decrypt", - ]), -}); diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 2e8ac551..32ad9a86 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -27,7 +27,6 @@ export const NOTE_TYPES = Object.freeze({ ADMIN_MODERATION_LIST: "adminModerationList", ADMIN_BLACKLIST: "adminBlacklist", ADMIN_WHITELIST: "adminWhitelist", - STORAGE_CHALLENGE: "storageChallenge", PROFILE_METADATA: "profileMetadata", }); @@ -449,17 +448,6 @@ const BASE_SCHEMAS = { appendTags: DEFAULT_APPEND_TAGS, content: { format: "empty", description: "Content field unused." }, }, - [NOTE_TYPES.STORAGE_CHALLENGE]: { - type: NOTE_TYPES.STORAGE_CHALLENGE, - label: "Storage challenge", - kind: 22242, - appendTags: [["purpose", "bitvid-storage-key"]], - content: { - format: "empty", - description: - "Ephemeral challenge event used for key derivation; never published.", - }, - }, [NOTE_TYPES.PROFILE_METADATA]: { type: NOTE_TYPES.PROFILE_METADATA, label: "Profile metadata", @@ -1554,31 +1542,6 @@ export function buildAdminListEvent(listKey, params) { }; } -export function buildStorageChallengeEvent(params) { - const { - pubkey, - created_at, - challenge, - } = params || {}; - const schema = getNostrEventSchema(NOTE_TYPES.STORAGE_CHALLENGE); - const tags = []; - - const normalizedChallenge = typeof challenge === "string" ? challenge.trim() : ""; - if (normalizedChallenge) { - tags.push(["challenge", normalizedChallenge]); - } - - appendSchemaTags(tags, schema); - - return { - kind: schema?.kind ?? 22242, - pubkey, - created_at, - tags, - content: "", - }; -} - if (typeof window !== "undefined") { window.bitvidNostrEvents = { NOTE_TYPES, diff --git a/js/services/signerKeyManager.js b/js/services/signerKeyManager.js deleted file mode 100644 index a428b863..00000000 --- a/js/services/signerKeyManager.js +++ /dev/null @@ -1,139 +0,0 @@ -import { devLogger } from "../utils/logger.js"; -import { buildStorageChallengeEvent } from "../nostrEventSchemas.js"; - -/** - * Generates a random 32-byte hex challenge string. - * @returns {string} The challenge string. - */ -function generateRandomChallenge() { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - return Array.from(bytes) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -/** - * Requests a signature for a storage key derivation challenge. - * - * @param {object} params - * @param {object} params.signer - The active signer (must implement signEvent). - * @param {string} params.pubkey - The user's public key (hex). - * @returns {Promise} The signature (hex string). - */ -export async function requestStorageSignature({ signer, pubkey }) { - if (!signer || typeof signer.signEvent !== "function") { - throw new Error("Invalid signer provided"); - } - if (!pubkey) { - throw new Error("Missing pubkey"); - } - - const challenge = generateRandomChallenge(); - const event = buildStorageChallengeEvent({ - pubkey, - created_at: Math.floor(Date.now() / 1000), - challenge, - }); - - devLogger.info("[SignerKeyManager] Requesting signature for storage challenge"); - - const signedEvent = await signer.signEvent(event); - - if (!signedEvent.sig) { - throw new Error("Signer returned event without signature"); - } - - // Basic validation that the signer actually signed *our* event - // (In a real scenario, we might also verify the signature against the pubkey, - // but here we trust the signer returned what we asked for if the IDs match). - if (signedEvent.content !== event.content || signedEvent.kind !== event.kind) { - devLogger.warn("[SignerKeyManager] Signer returned modified event structure"); - } - - // Mask the signature in logs if we were to log it (we won't). - devLogger.info("[SignerKeyManager] Received signature"); - - return signedEvent.sig; -} - -/** - * Derives an AES-GCM CryptoKey from a signature. - * - * @param {string} signature - The hex signature string. - * @returns {Promise} The derived symmetric key. - */ -export async function deriveKeyFromSignature(signature) { - if (!signature || typeof signature !== "string") { - throw new Error("Invalid signature"); - } - - // 1. Convert hex signature to Uint8Array - const signatureBytes = new Uint8Array( - signature.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)) - ); - - // 2. Hash the signature with SHA-256 to get uniform key material - const hashBuffer = await crypto.subtle.digest("SHA-256", signatureBytes); - - // 3. Import as AES-GCM key - const key = await crypto.subtle.importKey( - "raw", - hashBuffer, - { name: "AES-GCM" }, - false, // not extractable - ["encrypt", "decrypt"] - ); - - return key; -} - -/** - * Generates a random 16-byte salt for PBKDF2. - * @returns {Uint8Array} The salt. - */ -export function generatePassphraseSalt() { - const salt = new Uint8Array(16); - crypto.getRandomValues(salt); - return salt; -} - -/** - * Derives an AES-GCM CryptoKey from a passphrase using PBKDF2. - * - * @param {string} passphrase - The user's passphrase. - * @param {Uint8Array} salt - The salt. - * @returns {Promise} The derived symmetric key. - */ -export async function deriveKeyFromPassphrase(passphrase, salt) { - if (!passphrase) { - throw new Error("Passphrase required"); - } - if (!salt || !(salt instanceof Uint8Array) || salt.length !== 16) { - throw new Error("Invalid salt (must be 16 bytes)"); - } - - const enc = new TextEncoder(); - const keyMaterial = await crypto.subtle.importKey( - "raw", - enc.encode(passphrase), - { name: "PBKDF2" }, - false, - ["deriveKey"] - ); - - const key = await crypto.subtle.deriveKey( - { - name: "PBKDF2", - salt: salt, - iterations: 100000, - hash: "SHA-256", - }, - keyMaterial, - { name: "AES-GCM", length: 256 }, - false, - ["encrypt", "decrypt"] - ); - - return key; -} diff --git a/js/watchHistorySort.js b/js/watchHistorySort.js deleted file mode 100644 index 3d9dcefa..00000000 --- a/js/watchHistorySort.js +++ /dev/null @@ -1,45 +0,0 @@ -// js/watchHistorySort.js - -export function toNumber(value, fallback) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : fallback; -} - -export function getWatchedAtScore(video) { - if (!video || typeof video !== "object") { - return Number.NEGATIVE_INFINITY; - } - - const watchedAt = toNumber(video?.watchHistory?.watchedAt, Number.NaN); - if (Number.isFinite(watchedAt)) { - return watchedAt; - } - - const createdAtSeconds = toNumber(video?.created_at, Number.NaN); - if (Number.isFinite(createdAtSeconds)) { - return createdAtSeconds * 1000; - } - - return Number.NEGATIVE_INFINITY; -} - -export function compareByWatchedAtDesc(a, b) { - const aScore = getWatchedAtScore(a); - const bScore = getWatchedAtScore(b); - - if (aScore !== bScore) { - return bScore - aScore; - } - - const aCreated = toNumber(a?.created_at, Number.NEGATIVE_INFINITY); - const bCreated = toNumber(b?.created_at, Number.NEGATIVE_INFINITY); - - if (aCreated !== bCreated) { - return bCreated - aCreated; - } - - const aId = typeof a?.id === "string" ? a.id : ""; - const bId = typeof b?.id === "string" ? b.id : ""; - - return aId.localeCompare(bId); -} diff --git a/package.json b/package.json index a986198e..f17b8aee 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,6 @@ "license": "GPL-3.0-or-later", "dependencies": { "@floating-ui/dom": "^1.7.4", - "path": "^0.12.7", - "webtorrent": "^1.9.7", "ws": "^8.19.0" } } diff --git a/tests/hashtag-preferences.test.mjs b/tests/hashtag-preferences.test.mjs index aa2fcc4c..babd12f2 100644 --- a/tests/hashtag-preferences.test.mjs +++ b/tests/hashtag-preferences.test.mjs @@ -268,32 +268,24 @@ test( const pubkey = "e".repeat(64); const encryptCalls = []; + // The service relies on the signer interface, so we mock the encryption methods on the signer directly. + // The "fallback" logic is handled by the signer implementation (e.g. Nip07Signer) in the real app. setActiveSigner({ type: "inpage", signEvent: async (event) => ({ ...event, id: "signed-window" }), + nip44Encrypt: async (target, plaintext) => { + // The service calls this for both nip44_v2 and nip44 schemes. + // Since nip44_v2 is registered first, it will be called first. + // We return success immediately, so loop breaks. + encryptCalls.push({ scheme: "nip44_call", target, plaintext }); + return "cipher-from-window"; + }, + nip04Encrypt: async (target, plaintext) => { + encryptCalls.push({ scheme: "nip04", target, plaintext }); + return "cipher-nip04"; + } }); - window.nostr = { - nip44: { - encrypt: async (target, plaintext) => { - encryptCalls.push({ scheme: "nip44", target, plaintext }); - throw new Error("nip44 failed"); - }, - v2: { - encrypt: async (target, plaintext) => { - encryptCalls.push({ scheme: "nip44_v2", target, plaintext }); - return "cipher-from-window"; - }, - }, - }, - nip04: { - encrypt: async (target, plaintext) => { - encryptCalls.push({ scheme: "nip04", target, plaintext }); - return "cipher-nip04"; - }, - }, - }; - const publishedEvents = []; nostrClient.pool = { list: async () => [], @@ -318,19 +310,19 @@ test( const signedEvent = await hashtagPreferences.publish(); assert.equal(signedEvent.id, "signed-window"); - assert.equal(encryptCalls.length >= 2, true); - assert.equal(encryptCalls[0].scheme, "nip44"); - assert.equal(encryptCalls[1].scheme, "nip44_v2"); + // We expect 1 call since the first scheme (nip44_v2) succeeds + assert.equal(encryptCalls.length, 1); + assert.equal(encryptCalls[0].scheme, "nip44_call"); assert.equal(encryptCalls[0].target, pubkey); - assert.equal(encryptCalls[1].target, pubkey); assert.ok(encryptCalls[0].plaintext.includes("windowtag")); - assert.ok(encryptCalls[1].plaintext.includes("windowtag")); + assert.equal(publishedEvents.length, 1); assert.deepEqual(publishedEvents[0].urls, ["wss://relay.window"]); assert.equal(publishedEvents[0].event.kind, 30015); const encryptedTag = publishedEvents[0].event.tags.find( (tag) => Array.isArray(tag) && tag[0] === "encrypted", ); + // nip44_v2 is the first tried scheme, so it is the one used assert.equal(encryptedTag[1], "nip44_v2"); }, ); diff --git a/tests/ui/signerKeyManager.test.mjs b/tests/ui/signerKeyManager.test.mjs deleted file mode 100644 index 404adfbc..00000000 --- a/tests/ui/signerKeyManager.test.mjs +++ /dev/null @@ -1,97 +0,0 @@ -import { test, describe, it, before, after } from "node:test"; -import assert from "node:assert"; -import { - deriveKeyFromSignature, - deriveKeyFromPassphrase, - generatePassphraseSalt, -} from "../../js/services/signerKeyManager.js"; -import { createUiDom } from "./helpers/jsdom-test-helpers.mjs"; - -describe("SignerKeyManager", () => { - let dom; - - before(async () => { - // We need a DOM environment because the code might rely on global crypto or other browser APIs - // Although Node 19+ has global crypto, the code might expect window.crypto. - // Let's check if the module uses `crypto.subtle` or `window.crypto.subtle`. - // The code imports `bytesToHex` but uses global `crypto`. - // The `signerKeyManager.js` uses `crypto.subtle`. - // Node.js has global `crypto` since v19, but let's ensure consistency with JSDOM just in case. - dom = createUiDom(); - - // Polyfill crypto if JSDOM doesn't fully support subtle crypto (it usually doesn't). - // Node.js has a native Web Crypto implementation at globalThis.crypto. - // If JSDOM overwrites it or if code expects it on window, we map it. - if (!globalThis.crypto) { - // Should exist in Node environment, but let's be safe - const cryptoModule = await import("node:crypto"); - globalThis.crypto = cryptoModule.webcrypto; - } - }); - - after(() => { - if (dom) dom.cleanup(); - }); - - describe("deriveKeyFromSignature", () => { - it("should derive an AES-GCM key from a hex signature", async () => { - // Mock signature (64 bytes hex) - const mockSignature = "a".repeat(128); - - const key = await deriveKeyFromSignature(mockSignature); - - assert.ok(key, "Key should be generated"); - assert.strictEqual(key.algorithm.name, "AES-GCM", "Algorithm should be AES-GCM"); - assert.strictEqual(key.extractable, false, "Key should not be extractable"); - assert.deepStrictEqual(key.usages, ["encrypt", "decrypt"], "Key should have encrypt/decrypt usages"); - }); - - it("should throw error for invalid signature", async () => { - await assert.rejects( - async () => await deriveKeyFromSignature(null), - /Invalid signature/, - "Should reject null signature" - ); - }); - }); - - describe("deriveKeyFromPassphrase", () => { - it("should derive an AES-GCM key from passphrase and salt", async () => { - const passphrase = "correct horse battery staple"; - const salt = generatePassphraseSalt(); - - const key = await deriveKeyFromPassphrase(passphrase, salt); - - assert.ok(key, "Key should be generated"); - assert.strictEqual(key.algorithm.name, "AES-GCM", "Algorithm should be AES-GCM"); - assert.strictEqual(key.extractable, false, "Key should not be extractable"); - }); - - it("should generate different keys for different salts", async () => { - const passphrase = "password123"; - const salt1 = generatePassphraseSalt(); - const salt2 = generatePassphraseSalt(); - - // We can't export the keys to compare bits because they are non-extractable. - // However, we can test that they encrypt the same data to different ciphertexts (due to key difference). - // Actually, AES-GCM uses a random IV usually, so ciphertexts differ anyway. - // But fundamentally, if the function returns successfully for different salts, that's the main test here without export access. - - const key1 = await deriveKeyFromPassphrase(passphrase, salt1); - const key2 = await deriveKeyFromPassphrase(passphrase, salt2); - - assert.notDeepStrictEqual(salt1, salt2, "Salts should be random"); - assert.ok(key1, "Key1 generated"); - assert.ok(key2, "Key2 generated"); - }); - - it("should throw error if salt is invalid", async () => { - const passphrase = "pw"; - await assert.rejects( - async () => await deriveKeyFromPassphrase(passphrase, "bad-salt"), - /Invalid salt/, - "Should reject string salt (expects Uint8Array)" - ); - }); - }); -});