Skip to content

Instantly share code, notes, and snippets.

@gitawego
Last active January 23, 2025 08:20
Show Gist options
  • Save gitawego/5775cdc0afebe84bcf1a2a4867d9fa22 to your computer and use it in GitHub Desktop.
Save gitawego/5775cdc0afebe84bcf1a2a4867d9fa22 to your computer and use it in GitHub Desktop.
Secure storage
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