Last active
January 23, 2025 08:20
-
-
Save gitawego/5775cdc0afebe84bcf1a2a4867d9fa22 to your computer and use it in GitHub Desktop.
Secure storage
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export interface SecureStorageOptions { | |
useLocalStorage?: boolean; | |
disableAutoGenKey?: boolean; | |
} | |
export class SecureStorage { | |
private readonly algorithm = 'AES-GCM'; | |
private readonly keyLength = 256; | |
private readonly ivLength = 12; // 96-bit IV for AES-GCM | |
private key: CryptoKey | null = null; | |
private storage: Storage; | |
constructor(readonly options: SecureStorageOptions = {}) { | |
this.storage = !options.useLocalStorage ? sessionStorage : localStorage; | |
} | |
// Generate a new AES-GCM key | |
async generateKey(): Promise<void> { | |
this.key = await crypto.subtle.generateKey( | |
{ | |
name: this.algorithm, | |
length: this.keyLength, | |
}, | |
true, // extractable | |
['encrypt', 'decrypt'] | |
); | |
} | |
// Import an existing key from raw format | |
async importKey(rawKey: ArrayBuffer): Promise<void> { | |
this.key = await crypto.subtle.importKey( | |
'raw', | |
rawKey, | |
{ | |
name: this.algorithm, | |
length: this.keyLength, | |
}, | |
true, // extractable | |
['encrypt', 'decrypt'] | |
); | |
} | |
// Export the current key in raw format | |
async exportKey(): Promise<ArrayBuffer> { | |
if (!this.key) { | |
throw new Error('No key available to export.'); | |
} | |
return crypto.subtle.exportKey('raw', this.key); | |
} | |
// Encrypt data, optionally embed the key, and store it | |
async setItem( | |
key: string, | |
value: unknown, | |
storeKey: boolean = false | |
): Promise<void> { | |
if (!this.key && !this.options.disableAutoGenKey) { | |
await this.generateKey(); | |
} | |
if (!this.key) { | |
throw new Error('Encryption key is not set.'); | |
} | |
const encoder = new TextEncoder(); | |
const data = encoder.encode(JSON.stringify(value)); | |
const iv = crypto.getRandomValues(new Uint8Array(this.ivLength)); // Generate a random IV | |
const encryptedData = await crypto.subtle.encrypt( | |
{ | |
name: this.algorithm, | |
iv, | |
}, | |
this.key, | |
data | |
); | |
const rawKey = storeKey | |
? await crypto.subtle.exportKey('raw', this.key) | |
: null; | |
// Combine raw key (if provided), IV, and encrypted data | |
const totalLength = | |
(rawKey ? rawKey.byteLength : 0) + | |
iv.byteLength + | |
encryptedData.byteLength; | |
const combinedData = new Uint8Array(totalLength); | |
let offset = 0; | |
if (rawKey) { | |
combinedData.set(new Uint8Array(rawKey), offset); | |
offset += rawKey.byteLength; | |
} | |
combinedData.set(iv, offset); | |
offset += iv.byteLength; | |
combinedData.set(new Uint8Array(encryptedData), offset); | |
// Store the combined data as a Base64 string | |
this.storage.setItem( | |
key, | |
this.arrayBufferToBase64(combinedData.buffer) | |
); | |
} | |
// Retrieve data, optionally extract the key, and decrypt | |
async getItem<T = any>(key: string): Promise<T | null> { | |
const storedData = this.storage.getItem(key); | |
if (!storedData) { | |
return null; | |
} | |
const combinedDataBuffer = this.base64ToArrayBuffer(storedData); | |
const combinedData = new Uint8Array(combinedDataBuffer); | |
let offset = 0; | |
// Determine if the key is embedded | |
let cryptoKey = this.key; | |
const isKeyEmbedded = combinedData.byteLength > this.ivLength + 1; | |
if (isKeyEmbedded) { | |
const rawKey = combinedData.slice(0, this.keyLength / 8); | |
offset += rawKey.byteLength; | |
cryptoKey = await crypto.subtle.importKey( | |
'raw', | |
rawKey.buffer, | |
{ | |
name: this.algorithm, | |
length: this.keyLength, | |
}, | |
true, // extractable | |
['decrypt'] | |
); | |
} | |
if (!cryptoKey) { | |
throw new Error(`no crypto key found`); | |
} | |
const iv = combinedData.slice(offset, offset + this.ivLength); | |
offset += this.ivLength; | |
const encryptedData = combinedData.slice(offset); | |
try { | |
const decryptedData = await crypto.subtle.decrypt( | |
{ | |
name: this.algorithm, | |
iv, | |
}, | |
cryptoKey, | |
encryptedData.buffer | |
); | |
const decoder = new TextDecoder(); | |
const result = decoder.decode(decryptedData); | |
return result && JSON.parse(result); | |
} catch (e) { | |
console.error('Decryption failed:', e); | |
return null; | |
} | |
} | |
// Helper function: Convert ArrayBuffer to Base64 | |
private arrayBufferToBase64(buffer: ArrayBuffer): string { | |
const bytes = new Uint8Array(buffer); | |
let binary = ''; | |
// eslint-disable-next-line no-return-assign | |
bytes.forEach(b => (binary += String.fromCharCode(b))); | |
return btoa(binary); | |
} | |
// Helper function: Convert Base64 to ArrayBuffer | |
private 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; | |
} | |
} | |
export default SecureStorage; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment