TODO: update vault.service.ts. Use MNEMONIC workflow to create real private_key_shard and content_inner_encrypted
108 lines
3.2 KiB
TypeScript
108 lines
3.2 KiB
TypeScript
/**
|
|
* Vault crypto: PBKDF2 key derivation + AES-256-GCM encrypt/decrypt.
|
|
* Matches backend SentinelVault semantics (PBKDF2 from mnemonic, AES-GCM).
|
|
* Uses Web Crypto API (crypto.subtle). Requires secure context / React Native polyfill if needed.
|
|
*/
|
|
|
|
const SALT = new TextEncoder().encode('Sentinel_Salt_2026');
|
|
const PBKDF2_ITERATIONS = 100000;
|
|
const AES_KEY_LEN = 256;
|
|
const GCM_IV_LEN = 16;
|
|
const GCM_TAG_LEN = 16;
|
|
|
|
function getCrypto(): Crypto {
|
|
if (typeof crypto !== 'undefined' && crypto.subtle) return crypto;
|
|
throw new Error('vaultCrypto: crypto.subtle not available');
|
|
}
|
|
|
|
/**
|
|
* Derive a 32-byte AES key from mnemonic phrase (space-separated words).
|
|
*/
|
|
export async function deriveKey(mnemonicPhrase: string, salt: Uint8Array = SALT): Promise<ArrayBuffer> {
|
|
const crypto = getCrypto();
|
|
const keyMaterial = await crypto.subtle.importKey(
|
|
'raw',
|
|
new TextEncoder().encode(mnemonicPhrase),
|
|
'PBKDF2',
|
|
false,
|
|
['deriveBits']
|
|
);
|
|
const saltBuf = salt.buffer.slice(salt.byteOffset, salt.byteOffset + salt.byteLength) as ArrayBuffer;
|
|
const bits = await crypto.subtle.deriveBits(
|
|
{
|
|
name: 'PBKDF2',
|
|
salt: saltBuf,
|
|
iterations: PBKDF2_ITERATIONS,
|
|
hash: 'SHA-256',
|
|
},
|
|
keyMaterial,
|
|
AES_KEY_LEN
|
|
);
|
|
return bits;
|
|
}
|
|
|
|
/**
|
|
* Encrypt plaintext with AES-256-GCM. Returns nonce(16) + tag(16) + ciphertext (matches Python SentinelVault).
|
|
*/
|
|
export async function encryptDataGCM(key: ArrayBuffer, plaintext: string): Promise<Uint8Array> {
|
|
const crypto = getCrypto();
|
|
const iv = crypto.getRandomValues(new Uint8Array(GCM_IV_LEN));
|
|
const cryptoKey = await crypto.subtle.importKey(
|
|
'raw',
|
|
key,
|
|
{ name: 'AES-GCM' },
|
|
false,
|
|
['encrypt']
|
|
);
|
|
const encoded = new TextEncoder().encode(plaintext);
|
|
const ciphertextWithTag = await crypto.subtle.encrypt(
|
|
{ name: 'AES-GCM', iv, tagLength: GCM_TAG_LEN * 8 },
|
|
cryptoKey,
|
|
encoded
|
|
);
|
|
const out = new Uint8Array(iv.length + ciphertextWithTag.byteLength);
|
|
out.set(iv, 0);
|
|
out.set(new Uint8Array(ciphertextWithTag), iv.length);
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Decrypt blob from encryptDataGCM (nonce(16) + ciphertext+tag).
|
|
*/
|
|
export async function decryptDataGCM(key: ArrayBuffer, blob: Uint8Array): Promise<string> {
|
|
const crypto = getCrypto();
|
|
const iv = blob.subarray(0, GCM_IV_LEN);
|
|
const ciphertextWithTag = blob.subarray(GCM_IV_LEN);
|
|
const ivBuf = iv.buffer.slice(iv.byteOffset, iv.byteOffset + iv.byteLength) as ArrayBuffer;
|
|
const ctBuf = ciphertextWithTag.buffer.slice(
|
|
ciphertextWithTag.byteOffset,
|
|
ciphertextWithTag.byteOffset + ciphertextWithTag.byteLength
|
|
) as ArrayBuffer;
|
|
const cryptoKey = await crypto.subtle.importKey(
|
|
'raw',
|
|
key,
|
|
{ name: 'AES-GCM' },
|
|
false,
|
|
['decrypt']
|
|
);
|
|
const dec = await crypto.subtle.decrypt(
|
|
{ name: 'AES-GCM', iv: ivBuf, tagLength: GCM_TAG_LEN * 8 },
|
|
cryptoKey,
|
|
ctBuf
|
|
);
|
|
return new TextDecoder().decode(dec);
|
|
}
|
|
|
|
export function bytesToHex(bytes: Uint8Array): string {
|
|
return Array.from(bytes)
|
|
.map((b) => b.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
}
|
|
|
|
export function hexToBytes(hex: string): Uint8Array {
|
|
const len = hex.length / 2;
|
|
const out = new Uint8Array(len);
|
|
for (let i = 0; i < len; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
return out;
|
|
}
|