mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
aec80ca727
commit
f50eda7e7a
3 files changed
+176
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user