Exocore-Backend / dist /services /secureStore.js
ChoruYt's picture
Upload 38 files
a21d6a5 verified
Raw
History Blame Contribute Delete
5.36 kB
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.SecureStore = void 0;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const crypto_1 = __importDefault(require("crypto"));
const chacha_js_1 = require("@noble/ciphers/chacha.js");
const scrypt_js_1 = require("@noble/hashes/scrypt.js");
/**
* Two-layer authenticated encryption for the local user cache.
*
* plaintext
* │
* ▼ XChaCha20-Poly1305 (key1, 24-byte nonce)
* ciphertext_inner + tag
* │
* ▼ AES-256-GCM (key2, 12-byte nonce)
* ciphertext_outer + tag
*
* Both ciphers are AEAD (encrypt + authenticate). An attacker would need to
* break BOTH independent algorithms with two distinct keys to recover the
* data — current best public attacks against either cipher are infeasible.
*
* Keys are derived with scrypt (N=2^15, r=8, p=1) from a master secret kept
* outside the repo (env var DB_ENCRYPTION_KEY, otherwise auto-generated and
* stored in `local-db/.master.key` with 0600 permissions).
*/
const MAGIC = Buffer.from("EXOSEC2"); // file format marker, version 2
const SALT_LEN = 16;
const AES_NONCE_LEN = 12;
const XCHACHA_NONCE_LEN = 24;
function deriveKeys(masterKey, salt) {
// Single scrypt call → 64 bytes → split into two independent 32-byte keys.
const out = (0, scrypt_js_1.scrypt)(masterKey, salt, { N: 1 << 15, r: 8, p: 1, dkLen: 64 });
return {
inner: out.slice(0, 32),
outer: Buffer.from(out.slice(32, 64)),
};
}
function loadMasterKey(localDir) {
const envKey = process.env.DB_ENCRYPTION_KEY;
if (envKey && envKey.length >= 32) {
return crypto_1.default.createHash("sha256").update(envKey).digest();
}
const keyPath = path_1.default.join(localDir, ".master.key");
if (fs_1.default.existsSync(keyPath)) {
return fs_1.default.readFileSync(keyPath);
}
if (!fs_1.default.existsSync(localDir))
fs_1.default.mkdirSync(localDir, { recursive: true });
const key = crypto_1.default.randomBytes(32);
fs_1.default.writeFileSync(keyPath, key, { mode: 0o600 });
console.warn("[secure] generated new master key at local-db/.master.key — set DB_ENCRYPTION_KEY env var to override.");
return key;
}
class SecureStore {
masterKey;
constructor(localDir) {
this.masterKey = loadMasterKey(localDir);
}
/** Encrypt → returns binary blob (Buffer). */
encrypt(plaintext) {
const data = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext, "utf-8");
const salt = crypto_1.default.randomBytes(SALT_LEN);
const { inner, outer } = deriveKeys(this.masterKey, salt);
// Inner layer: XChaCha20-Poly1305
const xNonce = crypto_1.default.randomBytes(XCHACHA_NONCE_LEN);
const xChacha = (0, chacha_js_1.xchacha20poly1305)(inner, xNonce);
const innerCt = Buffer.from(xChacha.encrypt(data));
// Outer layer: AES-256-GCM
const aesNonce = crypto_1.default.randomBytes(AES_NONCE_LEN);
const aes = crypto_1.default.createCipheriv("aes-256-gcm", outer, aesNonce);
const outerCt = Buffer.concat([aes.update(innerCt), aes.final()]);
const aesTag = aes.getAuthTag();
// Layout: MAGIC | salt | aesNonce | aesTag | xNonce | outerCt
return Buffer.concat([MAGIC, salt, aesNonce, aesTag, xNonce, outerCt]);
}
/** Encrypt and write to a file (binary). */
encryptToFile(plaintext, filePath) {
const dir = path_1.default.dirname(filePath);
if (!fs_1.default.existsSync(dir))
fs_1.default.mkdirSync(dir, { recursive: true });
fs_1.default.writeFileSync(filePath, this.encrypt(plaintext), { mode: 0o600 });
}
/** Read an encrypted file and return the original utf-8 string. */
decryptFromFile(filePath) {
return this.decrypt(fs_1.default.readFileSync(filePath));
}
/** Decrypt → returns the original utf-8 string. Throws if tampered. */
decrypt(blob) {
let offset = 0;
const magic = blob.slice(offset, offset + MAGIC.length);
offset += MAGIC.length;
if (!magic.equals(MAGIC))
throw new Error("secure store: bad magic / unsupported format");
const salt = blob.slice(offset, offset + SALT_LEN);
offset += SALT_LEN;
const aesNonce = blob.slice(offset, offset + AES_NONCE_LEN);
offset += AES_NONCE_LEN;
const aesTag = blob.slice(offset, offset + 16);
offset += 16;
const xNonce = blob.slice(offset, offset + XCHACHA_NONCE_LEN);
offset += XCHACHA_NONCE_LEN;
const outerCt = blob.slice(offset);
const { inner, outer } = deriveKeys(this.masterKey, salt);
// Outer
const aes = crypto_1.default.createDecipheriv("aes-256-gcm", outer, aesNonce);
aes.setAuthTag(aesTag);
const innerCt = Buffer.concat([aes.update(outerCt), aes.final()]);
// Inner
const xChacha = (0, chacha_js_1.xchacha20poly1305)(inner, xNonce);
const plain = Buffer.from(xChacha.decrypt(innerCt));
return plain.toString("utf-8");
}
}
exports.SecureStore = SecureStore;