Add encrypted key support for chat and nodes

Introduces encrypted private key storage for nodes and users, updates chat message schema to support sender-side encryption, and adds supporting libraries and tests for cryptographic signing and identity unlock flows. Includes new database migrations, API route updates, and React components for identity unlock prompts.
This commit is contained in:
Christopher
2026-01-27 17:13:28 -08:00
parent 25f71e320b
commit 5903022f8a
46 changed files with 7457 additions and 113 deletions
+297
View File
@@ -0,0 +1,297 @@
/**
* User-Level Cryptographic Signing
*
* Strict Implementation Rules:
* - ECDSA P-256 (ES256) ONLY. No RSA.
* - Private keys NEVER in localStorage (decrypted).
* - Keys stored in memory only (InMemoryKeyStore).
* - Canonicalization: JSON with sorted keys, no floats, no dates.
* - Nonce: 16+ bytes random base64url.
*/
import { v4 as uuidv4 } from 'uuid';
// ============================================
// KEY STORAGE (In-Memory Only)
// ============================================
export interface KeyStore {
setPrivateKey(key: CryptoKey): void;
getPrivateKey(): CryptoKey | null;
clear(): void;
}
class InMemoryKeyStore implements KeyStore {
private static instance: InMemoryKeyStore;
private privateKey: CryptoKey | null = null;
private constructor() { }
static getInstance(): InMemoryKeyStore {
if (!InMemoryKeyStore.instance) {
InMemoryKeyStore.instance = new InMemoryKeyStore();
}
return InMemoryKeyStore.instance;
}
setPrivateKey(key: CryptoKey): void {
if (key.type !== 'private' || key.algorithm.name !== 'ECDSA') {
throw new Error('Invalid key type: Must be ECDSA private key');
}
this.privateKey = key;
}
getPrivateKey(): CryptoKey | null {
return this.privateKey;
}
clear(): void {
this.privateKey = null;
}
}
export const keyStore = InMemoryKeyStore.getInstance();
export function hasUserPrivateKey(): boolean {
return keyStore.getPrivateKey() !== null;
}
export function clearUserPrivateKey(): void {
keyStore.clear();
}
// ============================================
// CRYPTO HELPERS (WebCrypto / Node)
// ============================================
// Detect environment for Crypto
const cryptoSubtle = typeof window !== 'undefined'
? window.crypto.subtle // Browser
: (globalThis.crypto as any)?.subtle || require('crypto').webcrypto?.subtle; // Node
if (!cryptoSubtle) {
throw new Error('WebCrypto is not supported in this environment');
}
/**
* Generate a new ECDSA P-256 KeyPair
* Non-extractable private key by default (good practice),
* but we need to export it to encrypt with password.
*/
export async function generateKeyPair(): Promise<CryptoKeyPair> {
return await cryptoSubtle.generateKey(
{
name: 'ECDSA',
namedCurve: 'P-256',
},
true, // extractable (needed for export/encyrption)
['sign', 'verify']
);
}
/**
* Export Private Key to PKCS8 (for encryption/storage)
*/
export async function exportPrivateKey(key: CryptoKey): Promise<ArrayBuffer> {
return await cryptoSubtle.exportKey('pkcs8', key);
}
/**
* Import Private Key from PKCS8 (after decryption)
*/
export async function importPrivateKey(keyData: ArrayBuffer | Uint8Array): Promise<CryptoKey> {
return await cryptoSubtle.importKey(
'pkcs8',
keyData,
{
name: 'ECDSA',
namedCurve: 'P-256',
},
false, // not exportable once imported in memory
['sign']
);
}
/**
* Export Public Key to SPKI (for distribution)
* We usually want this as PEM or JWK. Let's stick to PEM buffer for consistency
* with existing storage, or simpler: just keep SPKI buffer and base64 it.
*/
export async function exportPublicKey(key: CryptoKey): Promise<string> {
const exported = await cryptoSubtle.exportKey('spki', key);
return arrayBufferToBase64(exported);
}
/**
* Import Public Key from SPKI Base64 (for verification)
*/
export async function importPublicKey(base64Key: string): Promise<CryptoKey> {
const binary = base64ToArrayBuffer(base64Key);
return await cryptoSubtle.importKey(
'spki',
binary,
{
name: 'ECDSA',
namedCurve: 'P-256',
},
true,
['verify']
);
}
// ============================================
// SIGNING & CANONICALIZATION
// ============================================
/**
* Strict Canonicalization for Signing
* - Request strict sorted keys
* - No Dates, Maps, Sets, Functions
* - No NaN, Infinity
*/
export function canonicalize(obj: any): string {
if (obj === undefined) return ''; // Should not happen for valid inputs
if (obj === null) return 'null';
if (typeof obj === 'number') {
if (!Number.isFinite(obj)) {
throw new Error('De-serialization failed: Number is not finite');
}
return obj.toString();
}
if (typeof obj === 'boolean') {
return obj.toString();
}
if (typeof obj === 'string') {
return JSON.stringify(obj);
}
if (Array.isArray(obj)) {
const items = obj.map(item => canonicalize(item)).join(',');
return `[${items}]`;
}
if (typeof obj === 'object') {
// Reject forbidden types
if (obj instanceof Date) throw new Error('Serialization failed: Date objects not allowed');
if (obj instanceof RegExp) throw new Error('Serialization failed: RegExp objects not allowed');
const keys = Object.keys(obj).sort();
const pairs = keys.map(key => {
const val = canonicalize(obj[key]);
return `${JSON.stringify(key)}:${val}`;
});
return `{${pairs.join(',')}}`;
}
throw new Error(`Serialization failed: Unsupported type ${typeof obj}`);
}
/**
* Create a Signed Action
* @returns { SignedAction } Includes ts, nonce, and strict signature
*/
export async function createSignedAction(
action: string,
data: any,
userDid: string,
userHandle: string
): Promise<{
action: string;
data: any;
did: string;
handle: string;
ts: number;
nonce: string;
sig: string;
}> {
const privateKey = keyStore.getPrivateKey();
if (!privateKey) {
throw new Error('User private key not available. Please log in (unlock identity).');
}
const ts = Date.now();
// 16 bytes entropy for nonce
const nonceBytes = new Uint8Array(16);
crypto.getRandomValues(nonceBytes);
const nonce = arrayBufferToBase64Url(nonceBytes);
// Payload strictly ordered for signing
// We construct the object that matches the canonical structure EXACTLY
const payloadToSign = {
action,
data,
did: userDid,
handle: userHandle,
nonce,
ts
};
const canonicalString = canonicalize(payloadToSign);
const encoder = new TextEncoder();
const dataBytes = encoder.encode(canonicalString);
const signatureBuffer = await cryptoSubtle.sign(
{
name: 'ECDSA',
hash: { name: 'SHA-256' },
},
privateKey,
dataBytes
);
const sig = arrayBufferToBase64Url(signatureBuffer);
return {
...payloadToSign,
sig
};
}
// ============================================
// UTILS
// ============================================
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function arrayBufferToBase64Url(buffer: ArrayBuffer | Uint8Array): string {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
/**
* Helper to convert Base64URL to Base64 (standard) for importing keys if needed
*/
export function base64UrlToBase64(base64Url: string): string {
let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
while (base64.length % 4) {
base64 += '=';
}
return base64;
}