Skip to content

Commit 3fa7bb2

Browse files
feat(crypto): implement Web Crypto API encryption for sensitive node config (#63)
1 parent be881d9 commit 3fa7bb2

5 files changed

Lines changed: 304 additions & 0 deletions

File tree

src/hooks/useConfigEncryption.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { useEffect, useState, useRef } from 'react';
2+
import { deriveSessionKey } from '../lib/crypto/cryptoEngine';
3+
4+
export function useConfigEncryption(sessionToken: string | null) {
5+
// We use a ref to prevent exposing the CryptoKey directly in state/DevTools snapshots
6+
const keyRef = useRef<WeakRef<CryptoKey> | null>(null);
7+
const saltRef = useRef<Uint8Array | null>(null);
8+
const [isReady, setIsReady] = useState(false);
9+
10+
useEffect(() => {
11+
let isMounted = true;
12+
13+
if (!sessionToken) {
14+
keyRef.current = null;
15+
saltRef.current = null;
16+
setIsReady(false);
17+
return;
18+
}
19+
20+
async function initializeKey() {
21+
try {
22+
const { key, salt } = await deriveSessionKey(sessionToken!);
23+
if (isMounted) {
24+
keyRef.current = new WeakRef(key);
25+
saltRef.current = salt;
26+
setIsReady(true);
27+
}
28+
} catch (error) {
29+
console.error('Failed to initialize configuration encryption keys:', error);
30+
if (isMounted) setIsReady(false);
31+
}
32+
}
33+
34+
initializeKey();
35+
36+
return () => {
37+
isMounted = false;
38+
// Break references explicitly to allow GC execution
39+
keyRef.current = null;
40+
saltRef.current = null;
41+
};
42+
}, [sessionToken]);
43+
44+
const getSessionKey = (): CryptoKey => {
45+
const key = keyRef.current?.deref();
46+
if (!key) {
47+
throw new Error('Encryption session key is unavailable or has been garbage collected.');
48+
}
49+
return key;
50+
};
51+
52+
return {
53+
isReady,
54+
getSessionKey,
55+
getSalt: () => saltRef.current,
56+
};
57+
}

src/hooks/useNodeConfig.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { useNodeConfigStore } from '../store/nodeConfigStore';
2+
import { encryptField, decryptField } from '../lib/crypto/cryptoEngine';
3+
import { saveToIndexedDB, loadFromIndexedDB } from '../lib/storage/idb';
4+
5+
// Define layout schema metadata
6+
const CONFIG_SCHEMA: Record<string, { sensitive: boolean }> = {
7+
rpcEndpoint: { sensitive: true },
8+
apiKey: { sensitive: true },
9+
sshCredentials: { sensitive: true },
10+
nodeName: { sensitive: false },
11+
};
12+
13+
export function useNodeConfig(getSessionKey: () => CryptoKey, getSalt: () => Uint8Array | null) {
14+
const store = useNodeConfigStore();
15+
16+
const loadConfig = async (nodeId: string) => {
17+
const rawPayload = await loadFromIndexedDB(nodeId);
18+
if (!rawPayload) return null;
19+
20+
const sessionKey = getSessionKey();
21+
const decryptedConfig: Record<string, any> = {};
22+
23+
for (const [key, value] of Object.entries(rawPayload)) {
24+
if (CONFIG_SCHEMA[key]?.sensitive && value && typeof value === 'object') {
25+
decryptedConfig[key] = await decryptField(value, sessionKey);
26+
} else {
27+
decryptedConfig[key] = value;
28+
}
29+
}
30+
31+
store.startEditing(decryptedConfig);
32+
};
33+
34+
const saveConfig = async (nodeId: string) => {
35+
if (!store.editingConfig) return;
36+
37+
const sessionKey = getSessionKey();
38+
const salt = getSalt();
39+
if (!salt) throw new Error('Salt configuration missing');
40+
41+
// Create shallow target object copy to execute transformations
42+
const payloadToPersist = { ...store.editingConfig };
43+
44+
for (const [key, value] of Object.entries(payloadToPersist)) {
45+
if (CONFIG_SCHEMA[key]?.sensitive && typeof value === 'string') {
46+
// 1. Encrypt field mutation
47+
payloadToPersist[key] = await encryptField(value, sessionKey, salt);
48+
49+
// 2. Strict Memory Hygiene: Purge plaintexts from the working copy immediately
50+
if (store.editingConfig[key]) {
51+
store.editingConfig[key] = null;
52+
delete store.editingConfig[key];
53+
}
54+
}
55+
}
56+
57+
// Persist finalized structural envelope safely to DB
58+
await saveToIndexedDB(nodeId, payloadToPersist);
59+
60+
// Wipe out state trace fully
61+
store.clearEditor();
62+
};
63+
64+
return {
65+
isEditorOpen: store.isEditorOpen,
66+
editingConfig: store.editingConfig,
67+
updateField: store.updateField,
68+
loadConfig,
69+
saveConfig,
70+
cancelEditing: store.clearEditor,
71+
};
72+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, it, expect } from 'vitest'; // or jest
2+
import { deriveSessionKey, encryptField, decryptField } from '../lib/crypto/cryptoEngine';
3+
4+
describe('Web Crypto Client-Side Configuration Encryption', () => {
5+
const mockToken = 'wallet-auth-session-token-xyz-12345';
6+
const plainTextSecret = 'https://eth-mainnet.g.alchemy.com/v2/secret-api-key';
7+
8+
it('should successfully execute an encrypt -> decrypt round-trip loop', async () => {
9+
const { key, salt } = await deriveSessionKey(mockToken);
10+
11+
const envelope = await encryptField(plainTextSecret, key, salt);
12+
13+
expect(envelope).toHaveProperty('iv');
14+
expect(envelope).toHaveProperty('salt');
15+
expect(envelope).toHaveProperty('ciphertext');
16+
expect(envelope.version).toBe(1);
17+
18+
const decryptedText = await decryptField(envelope, key);
19+
expect(decryptedText).toBe(plainTextSecret);
20+
});
21+
22+
it('should fail decryption if a different session key is supplied', async () => {
23+
const { key: trueKey, salt } = await deriveSessionKey(mockToken);
24+
const { key: wrongKey } = await deriveSessionKey('completely-different-token');
25+
26+
const envelope = await encryptField(plainTextSecret, trueKey, salt);
27+
28+
await expect(decryptField(envelope, wrongKey)).rejects.toThrow();
29+
});
30+
31+
it('should guarantee unique IV strings across independent invocations', async () => {
32+
const { key, salt } = await deriveSessionKey(mockToken);
33+
34+
const envelope1 = await encryptField(plainTextSecret, key, salt);
35+
const envelope2 = await encryptField(plainTextSecret, key, salt);
36+
37+
expect(envelope1.iv).not.toBe(envelope2.iv);
38+
expect(envelope1.ciphertext).not.toBe(envelope2.ciphertext);
39+
});
40+
});

src/lib/crypto/cryptoEngine.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
export interface EncryptedEnvelope {
2+
iv: string; // Base64
3+
salt: string; // Base64
4+
ciphertext: string; // Base64
5+
version: number;
6+
}
7+
8+
// Helper utilities for ArrayBuffer <-> Base64/String conversions
9+
const textEncoder = new TextEncoder();
10+
const textDecoder = new TextDecoder();
11+
12+
function bufferToBase64(buffer: ArrayBuffer): string {
13+
return btoa(String.fromCharCode(...new Uint8Array(buffer)));
14+
}
15+
16+
function base64ToBuffer(base64: string): ArrayBuffer {
17+
const binaryString = atob(base64);
18+
const bytes = new Uint8Array(binaryString.length);
19+
for (let i = 0; i < binaryString.length; i++) {
20+
bytes[i] = binaryString.charCodeAt(i);
21+
}
22+
return bytes.buffer;
23+
}
24+
25+
/**
26+
* Derives a 256-bit AES-GCM CryptoKey from a session token using PBKDF2.
27+
*/
28+
export async function deriveSessionKey(sessionToken: string, customSalt?: ArrayBuffer): Promise<{ key: CryptoKey; salt: Uint8Array }> {
29+
const tokenBytes = textEncoder.encode(sessionToken);
30+
const salt = customSalt ? new Uint8Array(customSalt) : window.crypto.getRandomValues(new Uint8Array(16));
31+
32+
const baseKey = await window.crypto.subtle.importKey(
33+
'raw',
34+
tokenBytes,
35+
'PBKDF2',
36+
false,
37+
['deriveKey']
38+
);
39+
40+
const key = await window.crypto.subtle.deriveKey(
41+
{
42+
name: 'PBKDF2',
43+
salt: salt,
44+
iterations: 600000,
45+
hash: 'SHA-256',
46+
},
47+
baseKey,
48+
{ name: 'AES-GCM', length: 256 },
49+
false, // Key is non-extractable for security
50+
['encrypt', 'decrypt']
51+
);
52+
53+
return { key, salt };
54+
}
55+
56+
/**
57+
* Encrypts a plaintext string using AES-GCM 256-bit with a session key.
58+
*/
59+
export async function encryptField(plaintext: string, sessionKey: CryptoKey, salt: Uint8Array): Promise<EncryptedEnvelope> {
60+
const iv = window.crypto.getRandomValues(new Uint8Array(12));
61+
const encodedPlaintext = textEncoder.encode(plaintext);
62+
63+
const ciphertextBuffer = await window.crypto.subtle.encrypt(
64+
{
65+
name: 'AES-GCM',
66+
iv: iv,
67+
},
68+
sessionKey,
69+
encodedPlaintext
70+
);
71+
72+
return {
73+
iv: bufferToBase64(iv.buffer),
74+
salt: bufferToBase64(salt.buffer),
75+
ciphertext: bufferToBase64(ciphertextBuffer),
76+
version: 1,
77+
};
78+
}
79+
80+
/**
81+
* Decrypts an EncryptedEnvelope back into a plaintext string.
82+
*/
83+
export async function decryptField(envelope: EncryptedEnvelope, sessionKey: CryptoKey): Promise<string> {
84+
if (envelope.version !== 1) {
85+
throw new Error(`Unsupported encryption version: ${envelope.version}`);
86+
}
87+
88+
const iv = new Uint8Array(base64ToBuffer(envelope.iv));
89+
const ciphertext = base64ToBuffer(envelope.ciphertext);
90+
91+
const decryptedBuffer = await window.crypto.subtle.decrypt(
92+
{
93+
name: 'AES-GCM',
94+
iv: iv,
95+
},
96+
sessionKey,
97+
ciphertext
98+
);
99+
100+
return textDecoder.decode(decryptedBuffer);
101+
}

src/store/nodeConfigStore.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { create } from 'zustand';
2+
3+
interface NodeConfigState {
4+
editingConfig: Record<string, any> | null;
5+
isEditorOpen: boolean;
6+
startEditing: (initialConfig: Record<string, any>) => void;
7+
updateField: (key: string, value: any) => void;
8+
clearEditor: () => void;
9+
}
10+
11+
export const useNodeConfigStore = create<NodeConfigState>((set) => ({
12+
editingConfig: null,
13+
isEditorOpen: false,
14+
15+
startEditing: (initialConfig) => set({
16+
editingConfig: { ...initialConfig },
17+
isEditorOpen: true
18+
}),
19+
20+
updateField: (key, value) => set((state) => ({
21+
editingConfig: state.editingConfig ? { ...state.editingConfig, [key]: value } : null
22+
})),
23+
24+
clearEditor: () => set((state) => {
25+
// Explicitly overwrite plaintext values in memory before dropping reference
26+
if (state.editingConfig) {
27+
Object.keys(state.editingConfig).forEach((key) => {
28+
state.editingConfig![key] = null;
29+
delete state.editingConfig![key];
30+
});
31+
}
32+
return { editingConfig: null, isEditorOpen: false };
33+
}),
34+
}));

0 commit comments

Comments
 (0)