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).
This commit is contained in:
google-labs-jules[bot] committed 2026-01-16 01:37:14 +00:00
1 parent aec80ca727
commit f50eda7e7a
3 files changed
+176

No files matched your search

+1
View File
@@ -152,6 +152,7 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
| Admin moderation list (`NOTE_TYPES.ADMIN_MODERATION_LIST`) | `30000` | `['d', 'bitvid:admin:editors']`, repeated `['p', <pubkey>]` entries | Empty content |
| Admin blacklist (`NOTE_TYPES.ADMIN_BLACKLIST`) | `30000` | `['d', 'bitvid:admin:blacklist']`, repeated `['p', <pubkey>]` entries | Empty content |
| Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', <pubkey>]` entries | Empty content |
| Storage challenge (`NOTE_TYPES.STORAGE_CHALLENGE`) | `22242` | `['challenge', <hex>]`, `['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`
+36
View File
@@ -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,
+139
View File
@@ -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<string>} 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<CryptoKey>} 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<CryptoKey>} 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;
}