Last active
August 2, 2025 07:26
-
-
Save panudetjt/a077af3fb47153d460347c4f67cbaf08 to your computer and use it in GitHub Desktop.
Encrypt/Decrypt by AI
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
import { base64url } from 'jose' | |
async function getCrypto(): Promise<SubtleCrypto> { | |
if (typeof globalThis !== 'undefined' && globalThis.crypto?.subtle) { | |
return globalThis.crypto.subtle | |
} | |
if (typeof window !== 'undefined' && window.crypto?.subtle) { | |
return window.crypto.subtle | |
} | |
const { webcrypto } = await import('node:crypto') | |
return webcrypto.subtle as SubtleCrypto | |
} | |
async function pbkdf2(password: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> { | |
const subtle = await getCrypto() | |
const encoder = new TextEncoder() | |
const passwordBytes = encoder.encode(password) | |
// Import password as key material | |
const keyMaterial = await subtle.importKey( | |
'raw', | |
passwordBytes, | |
'PBKDF2', | |
false, | |
['deriveKey'] | |
) | |
// Derive key using PBKDF2 (JOSE standard) | |
const derivedKey = await subtle.deriveKey( | |
{ | |
name: 'PBKDF2', | |
salt, | |
iterations, | |
hash: 'SHA-256' | |
}, | |
keyMaterial, | |
{ name: 'AES-GCM', length: 256 }, | |
true, | |
['encrypt', 'decrypt'] | |
) | |
// Export as raw bytes for use with AES-GCM | |
const keyBytes = await subtle.exportKey('raw', derivedKey) | |
return new Uint8Array(keyBytes) | |
} | |
async function aesGcmEncrypt(plaintext: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> { | |
const subtle = await getCrypto() | |
const cryptoKey = await subtle.importKey( | |
'raw', | |
key, | |
{ name: 'AES-GCM' }, | |
false, | |
['encrypt'] | |
) | |
const encrypted = await subtle.encrypt( | |
{ name: 'AES-GCM', iv }, | |
cryptoKey, | |
plaintext | |
) | |
return new Uint8Array(encrypted) | |
} | |
async function aesGcmDecrypt(ciphertext: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> { | |
const subtle = await getCrypto() | |
const cryptoKey = await subtle.importKey( | |
'raw', | |
key, | |
{ name: 'AES-GCM' }, | |
false, | |
['decrypt'] | |
) | |
const decrypted = await subtle.decrypt( | |
{ name: 'AES-GCM', iv }, | |
cryptoKey, | |
ciphertext | |
) | |
return new Uint8Array(decrypted) | |
} | |
export async function encryptSecret(payload: Record<string, any>, keyText: string): Promise<string> { | |
const encoder = new TextEncoder() | |
const plaintext = encoder.encode(JSON.stringify(payload)) | |
// Generate random salt and IV (following JOSE standards) | |
const salt = new Uint8Array(16) | |
const iv = new Uint8Array(12) // GCM recommended IV size | |
for (let i = 0; i < 16; i++) { | |
salt[i] = Math.floor(Math.random() * 256) | |
} | |
for (let i = 0; i < 12; i++) { | |
iv[i] = Math.floor(Math.random() * 256) | |
} | |
// Use PBKDF2 with 100k iterations (JOSE recommendation) | |
const key = await pbkdf2(keyText, salt, 100000) | |
// Encrypt using AES-GCM (JOSE standard) | |
const ciphertext = await aesGcmEncrypt(plaintext, key, iv) | |
// Create JWE-like structure | |
const jwePayload = { | |
protected: base64url.encode(new TextEncoder().encode(JSON.stringify({ | |
alg: 'PBES2-HS256+A128KW', | |
enc: 'A256GCM' | |
}))), | |
encrypted_key: '', | |
iv: base64url.encode(iv), | |
ciphertext: base64url.encode(ciphertext), | |
tag: '', | |
salt: base64url.encode(salt) | |
} | |
return base64url.encode(new TextEncoder().encode(JSON.stringify(jwePayload))) | |
} | |
export async function decryptSecret(encryptedData: string, keyText: string): Promise<Record<string, any>> { | |
const dataStr = new TextDecoder().decode(base64url.decode(encryptedData)) | |
const jwePayload = JSON.parse(dataStr) | |
const salt = base64url.decode(jwePayload.salt) | |
const iv = base64url.decode(jwePayload.iv) | |
const ciphertext = base64url.decode(jwePayload.ciphertext) | |
// Derive key using same PBKDF2 parameters | |
const key = await pbkdf2(keyText, salt, 100000) | |
try { | |
// Decrypt using AES-GCM | |
const plaintext = await aesGcmDecrypt(ciphertext, key, iv) | |
const decoder = new TextDecoder() | |
const payloadString = decoder.decode(plaintext) | |
return JSON.parse(payloadString) | |
} catch (error) { | |
throw new Error('Invalid key or corrupted data') | |
} | |
} |
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
import { describe, it, expect } from 'vitest' | |
import { encryptSecret, decryptSecret } from './crypto' | |
describe('crypto utilities', () => { | |
const testPayload = { | |
userId: '12345', | |
username: 'testuser', | |
roles: ['admin', 'user'], | |
metadata: { | |
lastLogin: '2024-01-01T00:00:00Z', | |
permissions: ['read', 'write'] | |
} | |
} | |
it('should encrypt and decrypt with simple key', async () => { | |
const key = 'my-simple-key' | |
const encrypted = await encryptSecret(testPayload, key) | |
expect(encrypted).toBeTruthy() | |
expect(typeof encrypted).toBe('string') | |
const decrypted = await decryptSecret(encrypted, key) | |
expect(decrypted).toEqual(testPayload) | |
}) | |
it('should encrypt and decrypt with long key', async () => { | |
const key = 'this-is-a-very-long-key-that-is-longer-than-32-characters-and-should-be-truncated' | |
const encrypted = await encryptSecret(testPayload, key) | |
const decrypted = await decryptSecret(encrypted, key) | |
expect(decrypted).toEqual(testPayload) | |
}) | |
it('should encrypt and decrypt with short key', async () => { | |
const key = 'short' | |
const encrypted = await encryptSecret(testPayload, key) | |
const decrypted = await decryptSecret(encrypted, key) | |
expect(decrypted).toEqual(testPayload) | |
}) | |
it('should encrypt and decrypt with special characters key', async () => { | |
const key = 'ключ-กุญแจ-🔑-密钥' | |
const encrypted = await encryptSecret(testPayload, key) | |
const decrypted = await decryptSecret(encrypted, key) | |
expect(decrypted).toEqual(testPayload) | |
}) | |
it('should fail with wrong key', async () => { | |
const correctKey = 'correct-key' | |
const wrongKey = 'wrong-key' | |
const encrypted = await encryptSecret(testPayload, correctKey) | |
await expect(decryptSecret(encrypted, wrongKey)).rejects.toThrow() | |
}) | |
it('should encrypt different data with same key', async () => { | |
const key = 'same-key' | |
const payload1 = { data: 'first' } | |
const payload2 = { data: 'second' } | |
const encrypted1 = await encryptSecret(payload1, key) | |
const encrypted2 = await encryptSecret(payload2, key) | |
expect(encrypted1).not.toBe(encrypted2) | |
const decrypted1 = await decryptSecret(encrypted1, key) | |
const decrypted2 = await decryptSecret(encrypted2, key) | |
expect(decrypted1).toEqual(payload1) | |
expect(decrypted2).toEqual(payload2) | |
}) | |
it('should handle empty string payload', async () => { | |
const key = 'test-key' | |
const payload = { message: '' } | |
const encrypted = await encryptSecret(payload, key) | |
const decrypted = await decryptSecret(encrypted, key) | |
expect(decrypted).toEqual(payload) | |
}) | |
it('should handle complex nested objects', async () => { | |
const key = 'complex-key' | |
const payload = { | |
level1: { | |
level2: { | |
level3: { | |
array: [1, 2, 3, { nested: true }], | |
boolean: false, | |
null: null, | |
number: 42.5 | |
} | |
} | |
} | |
} | |
const encrypted = await encryptSecret(payload, key) | |
const decrypted = await decryptSecret(encrypted, key) | |
expect(decrypted).toEqual(payload) | |
}) | |
}) |
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
import { base64url } from 'jose' | |
function simpleHash(input: Uint8Array): Uint8Array { | |
const hash = new Uint8Array(32) | |
for (let i = 0; i < input.length; i++) { | |
const idx1 = i % 32 | |
const idx2 = (i + 7) % 32 | |
hash[idx1] ^= input[i] || 0 | |
hash[idx2] ^= (input[i] || 0) * 17 | |
} | |
for (let round = 0; round < 1000; round++) { | |
const temp = new Uint8Array(32) | |
for (let i = 0; i < 32; i++) { | |
temp[i] = (hash[i] || 0) ^ (hash[(i + 1) % 32] || 0) ^ (hash[(i + 7) % 32] || 0) ^ (round & 0xFF) | |
} | |
hash.set(temp) | |
} | |
return hash | |
} | |
function xorEncrypt(data: Uint8Array, key: Uint8Array): Uint8Array { | |
const result = new Uint8Array(data.length) | |
for (let i = 0; i < data.length; i++) { | |
result[i] = (data[i] || 0) ^ (key[i % key.length] || 0) | |
} | |
return result | |
} | |
function createKeyFromText(keyText: string): Uint8Array { | |
const encoder = new TextEncoder() | |
const keyData = encoder.encode(keyText) | |
return simpleHash(keyData) | |
} | |
export async function encryptSecret(payload: Record<string, any>, keyText: string): Promise<string> { | |
const encoder = new TextEncoder() | |
const payloadString = JSON.stringify(payload) | |
const plaintext = encoder.encode(payloadString) | |
const key = createKeyFromText(keyText) | |
const iv = new Uint8Array(16) | |
for (let i = 0; i < 16; i++) { | |
iv[i] = Math.floor(Math.random() * 256) | |
} | |
const checksum = simpleHash(encoder.encode(payloadString + keyText)) | |
const keyWithIv = simpleHash(new Uint8Array([...key, ...iv])) | |
const encrypted = xorEncrypt(plaintext, keyWithIv) | |
const combined = new Uint8Array(checksum.length + iv.length + encrypted.length) | |
combined.set(checksum) | |
combined.set(iv, checksum.length) | |
combined.set(encrypted, checksum.length + iv.length) | |
return base64url.encode(combined) | |
} | |
export async function decryptSecret(encryptedData: string, keyText: string): Promise<Record<string, any>> { | |
const combined = base64url.decode(encryptedData) | |
const key = createKeyFromText(keyText) | |
const expectedChecksum = combined.slice(0, 32) | |
const iv = combined.slice(32, 48) | |
const encrypted = combined.slice(48) | |
const keyWithIv = simpleHash(new Uint8Array([...key, ...iv])) | |
const decrypted = xorEncrypt(encrypted, keyWithIv) | |
const decoder = new TextDecoder() | |
const payloadString = decoder.decode(decrypted) | |
try { | |
const payload = JSON.parse(payloadString) | |
const encoder = new TextEncoder() | |
const actualChecksum = simpleHash(encoder.encode(payloadString + keyText)) | |
for (let i = 0; i < 32; i++) { | |
if (expectedChecksum[i] !== actualChecksum[i]) { | |
throw new Error('Invalid key or corrupted data') | |
} | |
} | |
return payload | |
} catch (e) { | |
if (e instanceof SyntaxError) { | |
throw new Error('Invalid key or corrupted data') | |
} | |
throw e | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment