feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
initMasterKey, generateUserDek, encryptUserDek, decryptUserDek,
|
||||
encryptStateBlob, decryptStateBlob,
|
||||
} from './sessions.js';
|
||||
|
||||
describe('envelope encryption', () => {
|
||||
let dir: string;
|
||||
let masterKeyPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'crypto-test-'));
|
||||
masterKeyPath = join(dir, 'master.key');
|
||||
});
|
||||
|
||||
it('initMasterKey writes a 32-byte file when missing', () => {
|
||||
const key = initMasterKey(masterKeyPath);
|
||||
expect(key.length).toBe(32);
|
||||
const reread = initMasterKey(masterKeyPath);
|
||||
expect(reread.equals(key)).toBe(true);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('round-trips a user DEK through master key envelope', () => {
|
||||
const master = initMasterKey(masterKeyPath);
|
||||
const dek = generateUserDek();
|
||||
const enc = encryptUserDek(master, dek);
|
||||
const dec = decryptUserDek(master, enc);
|
||||
expect(dec.equals(dek)).toBe(true);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('round-trips a state blob through user DEK', () => {
|
||||
const dek = generateUserDek();
|
||||
const payload = JSON.stringify({ cookies: [{ name: 'sid', value: 'abc' }] });
|
||||
const enc = encryptStateBlob(dek, payload);
|
||||
const dec = decryptStateBlob(dek, enc);
|
||||
expect(dec).toBe(payload);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('decryptStateBlob throws on tampered ciphertext', () => {
|
||||
const dek = generateUserDek();
|
||||
const enc = encryptStateBlob(dek, 'hello');
|
||||
enc[enc.length - 1] ^= 0xff;
|
||||
expect(() => decryptStateBlob(dek, enc)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto';
|
||||
import { existsSync, readFileSync, writeFileSync, chmodSync, mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const ALGO = 'aes-256-gcm';
|
||||
const IV_LEN = 12;
|
||||
const TAG_LEN = 16;
|
||||
|
||||
/** Read or initialize the master key (32 bytes) at `path`. File mode is forced to 0600. */
|
||||
export function initMasterKey(path: string): Buffer {
|
||||
if (existsSync(path)) {
|
||||
const buf = readFileSync(path);
|
||||
if (buf.length !== 32) {
|
||||
throw new Error(`Master key at ${path} is not 32 bytes (got ${buf.length})`);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
||||
const key = randomBytes(32);
|
||||
writeFileSync(path, key, { mode: 0o600 });
|
||||
chmodSync(path, 0o600);
|
||||
return key;
|
||||
}
|
||||
|
||||
export function generateUserDek(): Buffer {
|
||||
return randomBytes(32);
|
||||
}
|
||||
|
||||
function encrypt(key: Buffer, plaintext: Buffer): Buffer {
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const cipher = createCipheriv(ALGO, key, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, ciphertext, tag]);
|
||||
}
|
||||
|
||||
function decrypt(key: Buffer, blob: Buffer): Buffer {
|
||||
if (blob.length < IV_LEN + TAG_LEN) throw new Error('blob too short');
|
||||
const iv = blob.subarray(0, IV_LEN);
|
||||
const tag = blob.subarray(blob.length - TAG_LEN);
|
||||
const ciphertext = blob.subarray(IV_LEN, blob.length - TAG_LEN);
|
||||
const decipher = createDecipheriv(ALGO, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
}
|
||||
|
||||
export function encryptUserDek(masterKey: Buffer, dek: Buffer): Buffer {
|
||||
return encrypt(masterKey, dek);
|
||||
}
|
||||
|
||||
export function decryptUserDek(masterKey: Buffer, enc: Buffer): Buffer {
|
||||
return decrypt(masterKey, enc);
|
||||
}
|
||||
|
||||
export function encryptStateBlob(dek: Buffer, plaintext: string): Buffer {
|
||||
return encrypt(dek, Buffer.from(plaintext, 'utf-8'));
|
||||
}
|
||||
|
||||
export function decryptStateBlob(dek: Buffer, blob: Buffer): string {
|
||||
return decrypt(dek, blob).toString('utf-8');
|
||||
}
|
||||
Reference in New Issue
Block a user