Spaces:
Running
Running
| ; | |
| 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; | |