From f50eda7e7a49522066725a5f190a34ebd74f858d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 16 Jan 2026 01:37:14 +0000 Subject: [PATCH] feat: add SignerKeyManager service for key derivation - Defines `STORAGE_CHALLENGE` event type (Kind 22242) in `js/nostrEventSchemas.js` and docs. - Implements `js/services/signerKeyManager.js` to derive symmetric keys from signer signatures or passphrases. - Validates inputs and returns safe `CryptoKey` objects (AES-GCM). --- docs/nostr-event-schemas.md | 1 + js/nostrEventSchemas.js | 36 +++++++++ js/services/signerKeyManager.js | 139 ++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 js/services/signerKeyManager.js diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 05c66dc1..fdddaf31 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -152,6 +152,7 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca | Admin moderation list (`NOTE_TYPES.ADMIN_MODERATION_LIST`) | `30000` | `['d', 'bitvid:admin:editors']`, repeated `['p', ]` entries | Empty content | | Admin blacklist (`NOTE_TYPES.ADMIN_BLACKLIST`) | `30000` | `['d', 'bitvid:admin:blacklist']`, repeated `['p', ]` entries | Empty content | | Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', ]` entries | Empty content | +| Storage challenge (`NOTE_TYPES.STORAGE_CHALLENGE`) | `22242` | `['challenge', ]`, `['purpose', 'bitvid-storage-key']` | Empty content; ephemeral event used for key derivation only. | Subscription lists therefore match the [NIP-51 follow-set specification](./nips/51.md#sets) by emitting kind `30000` diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index d3d28aaf..f4aa357e 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -26,6 +26,7 @@ export const NOTE_TYPES = Object.freeze({ ADMIN_MODERATION_LIST: "adminModerationList", ADMIN_BLACKLIST: "adminBlacklist", ADMIN_WHITELIST: "adminWhitelist", + STORAGE_CHALLENGE: "storageChallenge", }); export const SUBSCRIPTION_LIST_IDENTIFIER = "subscriptions"; @@ -446,6 +447,17 @@ 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.", + }, + }, }; let schemaOverrides = {}; @@ -1503,6 +1515,30 @@ export function buildAdminListEvent(listKey, { pubkey, created_at, hexPubkeys = }; } +export function buildStorageChallengeEvent({ + pubkey, + created_at, + challenge, +}) { + 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 new file mode 100644 index 00000000..a428b863 --- /dev/null +++ b/js/services/signerKeyManager.js @@ -0,0 +1,139 @@ +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; +}