feat(01-03): implement AES-256-GCM app-password encryption (T-03-01)
- encryptPassword: randomBytes(12) IV, aes-256-gcm, returns JSON {iv,authTag,ciphertext}
- decryptPassword: verifies GCM auth tag; throws on tamper
- Key from APP_PASSWORD_ENCRYPTION_KEY env (64-char hex); validated on each call
- No logging of plaintext or key
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* AES-256-GCM encryption helpers for Fastmail app-password storage.
|
||||
*
|
||||
* Security requirements (T-03-01, T-03-03, ASVS V6):
|
||||
* - Key from APP_PASSWORD_ENCRYPTION_KEY env (64-char hex = 32 bytes)
|
||||
* - 96-bit (12-byte) random IV per encryption — never reuse IV
|
||||
* - GCM auth tag verifies integrity on decrypt; tampered ciphertext throws
|
||||
* - Stored payload: JSON { iv, authTag, ciphertext } (all hex)
|
||||
* - Never log plaintext or the encryption key
|
||||
*
|
||||
* Source: Node.js docs node:crypto — AES-GCM
|
||||
*/
|
||||
|
||||
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto'
|
||||
|
||||
// Validated at module load: key must be present and 32 bytes (64 hex chars).
|
||||
// We deliberately do NOT throw here on missing key — that would crash the module
|
||||
// during tests that set the env before the first import. The KEY is only read
|
||||
// when encryptPassword / decryptPassword are actually called.
|
||||
function getKey(): Buffer {
|
||||
const hex = process.env.APP_PASSWORD_ENCRYPTION_KEY
|
||||
if (!hex || hex.length !== 64) {
|
||||
throw new Error(
|
||||
'APP_PASSWORD_ENCRYPTION_KEY must be a 64-character hex string (32 bytes). ' +
|
||||
'Generate with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"',
|
||||
)
|
||||
}
|
||||
return Buffer.from(hex, 'hex')
|
||||
}
|
||||
|
||||
interface EncryptedPayload {
|
||||
iv: string
|
||||
authTag: string
|
||||
ciphertext: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a plaintext string using AES-256-GCM.
|
||||
* Returns a JSON string with { iv, authTag, ciphertext } — all hex-encoded.
|
||||
* Each call generates a fresh random 96-bit IV.
|
||||
*/
|
||||
export function encryptPassword(plaintext: string): string {
|
||||
const key = getKey()
|
||||
const iv = randomBytes(12) // 96-bit IV for GCM
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||
const authTag = cipher.getAuthTag()
|
||||
const payload: EncryptedPayload = {
|
||||
iv: iv.toString('hex'),
|
||||
authTag: authTag.toString('hex'),
|
||||
ciphertext: encrypted.toString('hex'),
|
||||
}
|
||||
return JSON.stringify(payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts an AES-256-GCM encrypted payload produced by encryptPassword.
|
||||
* Throws if the auth tag does not match (integrity violation).
|
||||
*/
|
||||
export function decryptPassword(stored: string): string {
|
||||
const key = getKey()
|
||||
const { iv, authTag, ciphertext } = JSON.parse(stored) as EncryptedPayload
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'hex'))
|
||||
decipher.setAuthTag(Buffer.from(authTag, 'hex'))
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(ciphertext, 'hex')),
|
||||
decipher.final(),
|
||||
]).toString('utf8')
|
||||
}
|
||||
Reference in New Issue
Block a user