rspace-online/src/encryptid/key-derivation.ts

630 lines
16 KiB
TypeScript

/**
* EncryptID Key Derivation Module
*
* Derives application-specific cryptographic keys from WebAuthn PRF output
* or passphrase fallback. This is Layer 2 of the EncryptID architecture.
*/
import { bufferToBase64url, base64urlToBuffer } from './webauthn';
import { deriveEOAFromPRF } from './eoa-derivation';
import { p256 } from '@noble/curves/p256';
import { ed25519 } from '@noble/curves/ed25519';
// ============================================================================
// TYPES
// ============================================================================
export interface DerivedKeys {
/** AES-256-GCM key for file/data encryption */
encryptionKey: CryptoKey;
/** ECDSA P-256 key pair for signing */
signingKeyPair: CryptoKeyPair;
/** Ed25519 seed for DID key (raw bytes) */
didSeed: Uint8Array;
/** The DID identifier (did:key:z6Mk...) */
did: string;
/** Whether keys were derived from PRF (true) or passphrase (false) */
fromPRF: boolean;
/** secp256k1 private key for EOA wallet operations (derived from PRF only) */
eoaPrivateKey?: Uint8Array;
/** Ethereum address derived from the EOA private key */
eoaAddress?: string;
}
export interface EncryptedData {
ciphertext: ArrayBuffer;
iv: Uint8Array;
tag?: ArrayBuffer; // For AEAD modes, tag is included in ciphertext
}
export interface SignedData {
data: ArrayBuffer;
signature: ArrayBuffer;
publicKey: ArrayBuffer;
}
// ============================================================================
// KEY DERIVATION
// ============================================================================
/**
* EncryptID Key Manager
*
* Handles derivation and management of all cryptographic keys
* from a master secret (PRF output or passphrase-derived).
*/
export class EncryptIDKeyManager {
private masterKey: CryptoKey | null = null;
private derivedKeys: DerivedKeys | null = null;
private fromPRF: boolean = false;
private prfOutputRaw: Uint8Array | null = null;
/**
* Initialize from WebAuthn PRF output
*
* This is the preferred path - keys are derived directly from
* the hardware-backed PRF extension output.
*/
async initFromPRF(prfOutput: ArrayBuffer): Promise<void> {
// Copy raw PRF output for EOA derivation (uses @noble/hashes directly)
// Must copy — not just wrap — so clear() doesn't zero the caller's buffer
this.prfOutputRaw = new Uint8Array(new Uint8Array(prfOutput));
// Import PRF output as HKDF key material
this.masterKey = await crypto.subtle.importKey(
'raw',
prfOutput,
{ name: 'HKDF' },
false, // Not extractable
['deriveKey', 'deriveBits']
);
this.fromPRF = true;
this.derivedKeys = null; // Clear any existing derived keys
console.log('EncryptID: Key manager initialized from PRF output');
}
/**
* Initialize from passphrase (fallback for non-PRF authenticators)
*
* Uses PBKDF2 with high iteration count to derive master key
* from user's passphrase. The salt should be stored securely.
*/
async initFromPassphrase(
passphrase: string,
salt: Uint8Array
): Promise<void> {
const encoder = new TextEncoder();
// Import passphrase as PBKDF2 key
const passphraseKey = await crypto.subtle.importKey(
'raw',
encoder.encode(passphrase),
{ name: 'PBKDF2' },
false,
['deriveBits']
);
// Derive master key material using PBKDF2
// OWASP 2023 recommends 600,000 iterations for PBKDF2-SHA256
const masterKeyMaterial = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt: salt as BufferSource,
iterations: 600000,
hash: 'SHA-256',
},
passphraseKey,
256 // 32 bytes
);
// Import as HKDF key for further derivation
this.masterKey = await crypto.subtle.importKey(
'raw',
masterKeyMaterial,
{ name: 'HKDF' },
false,
['deriveKey', 'deriveBits']
);
this.fromPRF = false;
this.derivedKeys = null;
console.log('EncryptID: Key manager initialized from passphrase');
}
/**
* Generate a random salt for passphrase-based derivation
*/
static generateSalt(): Uint8Array {
return crypto.getRandomValues(new Uint8Array(32));
}
/**
* Check if the key manager is initialized
*/
isInitialized(): boolean {
return this.masterKey !== null;
}
/**
* Get or derive all application keys
*/
async getKeys(): Promise<DerivedKeys> {
if (!this.masterKey) {
throw new Error('Key manager not initialized');
}
// Return cached keys if available
if (this.derivedKeys) {
return this.derivedKeys;
}
// Derive all keys
const [encryptionKey, signingKeyPair, didSeed] = await Promise.all([
this.deriveEncryptionKey(),
this.deriveSigningKeyPair(),
this.deriveDIDSeed(),
]);
// Generate DID from seed
const did = await this.generateDID(didSeed);
// Derive EOA key (PRF-only — passphrase path doesn't get wallet keys)
const eoa = this.deriveEOAKey();
this.derivedKeys = {
encryptionKey,
signingKeyPair,
didSeed,
did,
fromPRF: this.fromPRF,
...(eoa && { eoaPrivateKey: eoa.privateKey, eoaAddress: eoa.address }),
};
return this.derivedKeys;
}
/**
* Derive AES-256-GCM encryption key
*/
private async deriveEncryptionKey(): Promise<CryptoKey> {
if (!this.masterKey) {
throw new Error('Key manager not initialized');
}
const encoder = new TextEncoder();
return crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: encoder.encode('encryptid-encryption-key-v1'),
info: encoder.encode('AES-256-GCM'),
},
this.masterKey,
{
name: 'AES-GCM',
length: 256,
},
false, // Not extractable
['encrypt', 'decrypt', 'wrapKey', 'unwrapKey']
);
}
/**
* Derive ECDSA P-256 signing key pair
*
* Note: WebCrypto doesn't support deterministic key generation,
* so we derive a seed and use it to generate the key pair.
* For production, consider using a library like @noble/curves.
*/
private async deriveSigningKeyPair(): Promise<CryptoKeyPair> {
if (!this.masterKey) {
throw new Error('Key manager not initialized');
}
const encoder = new TextEncoder();
// Derive seed for signing key
const seed = await crypto.subtle.deriveBits(
{
name: 'HKDF',
hash: 'SHA-256',
salt: encoder.encode('encryptid-signing-key-v1'),
info: encoder.encode('ECDSA-P256-seed'),
},
this.masterKey,
256
);
// Derive deterministic P-256 key pair from seed using @noble/curves
const seedBytes = new Uint8Array(seed);
const publicKeyUncompressed = p256.getPublicKey(seedBytes, false); // 65 bytes: 04 || x || y
// Import into WebCrypto as ECDSA key pair for sign/verify operations
// PKCS8 wrapping for private key import
const pkcs8 = buildP256Pkcs8(seedBytes);
const privateKey = await crypto.subtle.importKey(
'pkcs8',
pkcs8,
{ name: 'ECDSA', namedCurve: 'P-256' },
false, // Not extractable
['sign']
);
const publicKey = await crypto.subtle.importKey(
'raw',
publicKeyUncompressed as BufferSource,
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['verify']
);
return { privateKey, publicKey };
}
/**
* Derive Ed25519 seed for DID key
*/
private async deriveDIDSeed(): Promise<Uint8Array> {
if (!this.masterKey) {
throw new Error('Key manager not initialized');
}
const encoder = new TextEncoder();
const seed = await crypto.subtle.deriveBits(
{
name: 'HKDF',
hash: 'SHA-256',
salt: encoder.encode('encryptid-did-key-v1'),
info: encoder.encode('Ed25519-seed'),
},
this.masterKey,
256
);
return new Uint8Array(seed);
}
/**
* Generate did:key identifier from Ed25519 seed
*
* Format: did:key:z6Mk... (multicodec ed25519-pub + base58btc)
*/
private async generateDID(seed: Uint8Array): Promise<string> {
// Derive Ed25519 public key from seed using @noble/curves
const publicKeyBytes = ed25519.getPublicKey(seed);
// Multicodec prefix for Ed25519 public key: 0xed01
const multicodecKey = new Uint8Array(34);
multicodecKey[0] = 0xed;
multicodecKey[1] = 0x01;
multicodecKey.set(publicKeyBytes, 2);
// Encode as base58btc (did:key spec requires 'z' prefix + base58btc)
return `did:key:z${base58btcEncode(multicodecKey)}`;
}
/**
* Derive secp256k1 EOA key from PRF output.
*
* Only available when initialized from PRF (not passphrase).
* Uses @noble/curves + @noble/hashes for deterministic derivation
* with domain-separated HKDF salt/info.
*/
private deriveEOAKey(): { privateKey: Uint8Array; publicKey: Uint8Array; address: string } | null {
if (!this.fromPRF || !this.prfOutputRaw) {
return null;
}
const result = deriveEOAFromPRF(this.prfOutputRaw);
console.log('EncryptID: EOA key derived, address:', result.address);
return result;
}
/**
* Clear all keys from memory
*/
clear(): void {
// Zero out sensitive key material
if (this.derivedKeys?.eoaPrivateKey) {
this.derivedKeys.eoaPrivateKey.fill(0);
}
if (this.derivedKeys?.didSeed) {
this.derivedKeys.didSeed.fill(0);
}
if (this.prfOutputRaw) {
this.prfOutputRaw.fill(0);
this.prfOutputRaw = null;
}
this.masterKey = null;
this.derivedKeys = null;
this.fromPRF = false;
console.log('EncryptID: Key manager cleared');
}
}
// ============================================================================
// ENCRYPTION UTILITIES
// ============================================================================
/**
* Encrypt data using the encryption key
*/
export async function encryptData(
key: CryptoKey,
data: ArrayBuffer | Uint8Array | string
): Promise<EncryptedData> {
// Convert string to ArrayBuffer if needed
let plaintext: ArrayBuffer;
if (typeof data === 'string') {
plaintext = new TextEncoder().encode(data).buffer;
} else if (data instanceof Uint8Array) {
plaintext = data.buffer as ArrayBuffer;
} else {
plaintext = data;
}
// Generate random IV
const iv = crypto.getRandomValues(new Uint8Array(12));
// Encrypt
const ciphertext = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: iv,
},
key,
plaintext
);
return { ciphertext, iv };
}
/**
* Decrypt data using the encryption key
*/
export async function decryptData(
key: CryptoKey,
encrypted: EncryptedData
): Promise<ArrayBuffer> {
return crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: encrypted.iv as BufferSource,
},
key,
encrypted.ciphertext
);
}
/**
* Decrypt data and return as string
*/
export async function decryptDataAsString(
key: CryptoKey,
encrypted: EncryptedData
): Promise<string> {
const plaintext = await decryptData(key, encrypted);
return new TextDecoder().decode(plaintext);
}
// ============================================================================
// SIGNING UTILITIES
// ============================================================================
/**
* Sign data using the signing key
*/
export async function signData(
keyPair: CryptoKeyPair,
data: ArrayBuffer | Uint8Array | string
): Promise<SignedData> {
// Convert string to ArrayBuffer if needed
let dataBuffer: ArrayBuffer;
if (typeof data === 'string') {
dataBuffer = new TextEncoder().encode(data).buffer;
} else if (data instanceof Uint8Array) {
dataBuffer = data.buffer as ArrayBuffer;
} else {
dataBuffer = data;
}
// Sign
const signature = await crypto.subtle.sign(
{
name: 'ECDSA',
hash: 'SHA-256',
},
keyPair.privateKey,
dataBuffer
);
// Export public key for verification
const publicKey = await crypto.subtle.exportKey('raw', keyPair.publicKey);
return {
data: dataBuffer,
signature,
publicKey,
};
}
/**
* Verify a signature
*/
export async function verifySignature(
signed: SignedData
): Promise<boolean> {
// Import public key
const publicKey = await crypto.subtle.importKey(
'raw',
signed.publicKey,
{
name: 'ECDSA',
namedCurve: 'P-256',
},
false,
['verify']
);
// Verify
return crypto.subtle.verify(
{
name: 'ECDSA',
hash: 'SHA-256',
},
publicKey,
signed.signature,
signed.data
);
}
// ============================================================================
// KEY WRAPPING (FOR SHARING)
// ============================================================================
/**
* Wrap a key for sharing with another user
*
* Used in rfiles to share encrypted files - wrap the file key
* with the recipient's public key.
*/
export async function wrapKeyForRecipient(
keyToWrap: CryptoKey,
recipientPublicKey: CryptoKey
): Promise<ArrayBuffer> {
return crypto.subtle.wrapKey(
'raw',
keyToWrap,
recipientPublicKey,
{
name: 'RSA-OAEP',
}
);
}
/**
* Unwrap a key that was shared with us
*/
export async function unwrapSharedKey(
wrappedKey: ArrayBuffer,
privateKey: CryptoKey
): Promise<CryptoKey> {
return crypto.subtle.unwrapKey(
'raw',
wrappedKey,
privateKey,
{
name: 'RSA-OAEP',
},
{
name: 'AES-GCM',
length: 256,
},
false,
['encrypt', 'decrypt']
);
}
// ============================================================================
// HELPERS
// ============================================================================
/**
* Build a PKCS#8 DER wrapper around a raw P-256 private key (32 bytes)
* so it can be imported via WebCrypto's importKey('pkcs8', ...).
*
* Structure: SEQUENCE { version, AlgorithmIdentifier { ecPublicKey, P-256 }, OCTET STRING { SEQUENCE { version, privateKey } } }
*/
function buildP256Pkcs8(rawPrivateKey: Uint8Array): ArrayBuffer {
// DER-encoded PKCS#8 template for P-256 with a 32-byte private key
// Pre-computed ASN.1 header (version + algorithm identifier)
const header = new Uint8Array([
0x30, 0x41, // SEQUENCE (65 bytes total)
0x02, 0x01, 0x00, // INTEGER version = 0
0x30, 0x13, // SEQUENCE (AlgorithmIdentifier)
0x06, 0x07, // OID 1.2.840.10045.2.1 (ecPublicKey)
0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01,
0x06, 0x08, // OID 1.2.840.10045.3.1.7 (P-256)
0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07,
0x04, 0x27, // OCTET STRING (39 bytes)
0x30, 0x25, // SEQUENCE (37 bytes)
0x02, 0x01, 0x01, // INTEGER version = 1
0x04, 0x20, // OCTET STRING (32 bytes) — the private key
]);
const result = new Uint8Array(header.length + 32);
result.set(header);
result.set(rawPrivateKey, header.length);
return result.buffer;
}
/**
* Base58btc encoding (Bitcoin alphabet).
* Minimal implementation — no external dependency needed.
*/
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
function base58btcEncode(bytes: Uint8Array): string {
// Count leading zeros
let zeroes = 0;
for (const b of bytes) {
if (b !== 0) break;
zeroes++;
}
// Convert to base58 using bigint arithmetic
let num = BigInt(0);
for (const b of bytes) {
num = num * 256n + BigInt(b);
}
let encoded = '';
while (num > 0n) {
const remainder = Number(num % 58n);
num = num / 58n;
encoded = BASE58_ALPHABET[remainder] + encoded;
}
// Prepend '1' for each leading zero byte
return '1'.repeat(zeroes) + encoded;
}
// ============================================================================
// SINGLETON INSTANCE
// ============================================================================
// Global key manager instance
let keyManagerInstance: EncryptIDKeyManager | null = null;
/**
* Get the global EncryptID key manager instance
*/
export function getKeyManager(): EncryptIDKeyManager {
if (!keyManagerInstance) {
keyManagerInstance = new EncryptIDKeyManager();
}
return keyManagerInstance;
}
/**
* Reset the global key manager (e.g., on logout)
*/
export function resetKeyManager(): void {
if (keyManagerInstance) {
keyManagerInstance.clear();
keyManagerInstance = null;
}
}