- Pre-boot password gate (main.js) with language selector; backend starts only after unlock. - keylock.js: scrypt KEK + AES-256-GCM wrap/unwrap of the SSB secret and keyrings. - crypto.js: keyrings encrypted/decrypted with the KEK. - ssb_config.js: decrypted keys in memory (no plaintext secret on disk).
129 lines
4.5 KiB
JavaScript
129 lines
4.5 KiB
JavaScript
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const ssbKeys = require('../server/node_modules/ssb-keys');
|
|
|
|
const MAGIC = Buffer.from('OAES1\n', 'utf8');
|
|
const KEYLEN = 32;
|
|
const DEFAULT_SCRYPT = { N: 16384, r: 8, p: 1 };
|
|
const VERIFIER_PLAINTEXT = Buffer.from('oasis-keylock-v1', 'utf8');
|
|
|
|
let KEK = null; // SOLO en memoria
|
|
|
|
const maxmemFor = (s) => Math.max(64 * 1024 * 1024, 256 * s.N * s.r);
|
|
|
|
const deriveKEK = (password, salt, scrypt = DEFAULT_SCRYPT) =>
|
|
crypto.scryptSync(Buffer.from(String(password), 'utf8'), salt, KEYLEN,
|
|
{ N: scrypt.N, r: scrypt.r, p: scrypt.p, maxmem: maxmemFor(scrypt) });
|
|
|
|
const isWrapped = (buf) =>
|
|
Buffer.isBuffer(buf) && buf.length >= MAGIC.length && buf.subarray(0, MAGIC.length).equals(MAGIC);
|
|
|
|
const wrap = (buf, kek = KEK) => {
|
|
if (!kek) throw new Error('keylock: locked (no KEK)');
|
|
const iv = crypto.randomBytes(12);
|
|
const c = crypto.createCipheriv('aes-256-gcm', kek, iv);
|
|
const ct = Buffer.concat([c.update(buf), c.final()]);
|
|
const tag = c.getAuthTag();
|
|
return Buffer.concat([MAGIC, iv, tag, ct]);
|
|
};
|
|
|
|
const unwrap = (blob, kek = KEK) => {
|
|
if (!kek) throw new Error('keylock: locked (no KEK)');
|
|
if (!isWrapped(blob)) throw new Error('keylock: not a wrapped blob');
|
|
let o = MAGIC.length;
|
|
const iv = blob.subarray(o, o += 12);
|
|
const tag = blob.subarray(o, o += 16);
|
|
const ct = blob.subarray(o);
|
|
const d = crypto.createDecipheriv('aes-256-gcm', kek, iv);
|
|
d.setAuthTag(tag);
|
|
return Buffer.concat([d.update(ct), d.final()]);
|
|
};
|
|
|
|
const lockPath = (configPath) => path.join(configPath, 'keylock.json');
|
|
|
|
const isConfigured = (configPath) => {
|
|
try { return fs.existsSync(lockPath(configPath)); } catch (_) { return false; }
|
|
};
|
|
|
|
const setPassword = (configPath, password) => {
|
|
const salt = crypto.randomBytes(16);
|
|
const scrypt = DEFAULT_SCRYPT;
|
|
const kek = deriveKEK(password, salt, scrypt);
|
|
const verifier = wrap(VERIFIER_PLAINTEXT, kek);
|
|
const data = {
|
|
v: 1, kdf: 'scrypt', N: scrypt.N, r: scrypt.r, p: scrypt.p,
|
|
salt: salt.toString('hex'), verifier: verifier.toString('base64'),
|
|
};
|
|
fs.mkdirSync(configPath, { recursive: true, mode: 0o700 });
|
|
const tmp = lockPath(configPath) + '.tmp';
|
|
fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
|
|
fs.renameSync(tmp, lockPath(configPath));
|
|
KEK = kek;
|
|
return kek;
|
|
};
|
|
|
|
const verifyPassword = (configPath, password) => {
|
|
let data;
|
|
try { data = JSON.parse(fs.readFileSync(lockPath(configPath), 'utf8')); } catch (_) { return null; }
|
|
const salt = Buffer.from(data.salt, 'hex');
|
|
const scrypt = { N: data.N || DEFAULT_SCRYPT.N, r: data.r || DEFAULT_SCRYPT.r, p: data.p || DEFAULT_SCRYPT.p };
|
|
const kek = deriveKEK(password, salt, scrypt);
|
|
try {
|
|
if (unwrap(Buffer.from(data.verifier, 'base64'), kek).equals(VERIFIER_PLAINTEXT)) {
|
|
KEK = kek;
|
|
return kek;
|
|
}
|
|
} catch (_) {}
|
|
return null;
|
|
};
|
|
|
|
const getKEK = () => KEK;
|
|
const setKEK = (k) => { KEK = k; };
|
|
const isUnlocked = () => KEK != null;
|
|
const lock = () => { try { if (KEK) KEK.fill(0); } catch (_) {} KEK = null; };
|
|
|
|
const secretEncPath = (configPath) => path.join(configPath, 'secret.enc');
|
|
const legacySecretPath = (configPath) => path.join(configPath, 'secret');
|
|
|
|
const shred = (p) => {
|
|
try { fs.writeFileSync(p, crypto.randomBytes(Math.max(1, fs.statSync(p).size))); } catch (_) {}
|
|
try { fs.unlinkSync(p); } catch (_) {}
|
|
};
|
|
|
|
const writeSecret = (configPath, keys) => {
|
|
const blob = wrap(Buffer.from(JSON.stringify(keys), 'utf8'));
|
|
fs.mkdirSync(configPath, { recursive: true, mode: 0o700 });
|
|
const tmp = secretEncPath(configPath) + '.tmp';
|
|
fs.writeFileSync(tmp, blob, { mode: 0o600 });
|
|
fs.renameSync(tmp, secretEncPath(configPath));
|
|
};
|
|
|
|
const loadOrCreateSecret = (configPath) => {
|
|
if (!KEK) throw new Error('keylock: locked');
|
|
fs.mkdirSync(configPath, { recursive: true, mode: 0o700 });
|
|
const encP = secretEncPath(configPath);
|
|
if (fs.existsSync(encP)) {
|
|
return JSON.parse(unwrap(fs.readFileSync(encP)).toString('utf8'));
|
|
}
|
|
const legP = legacySecretPath(configPath);
|
|
if (fs.existsSync(legP)) {
|
|
const keys = ssbKeys.loadSync(legP);
|
|
writeSecret(configPath, keys);
|
|
shred(legP);
|
|
return keys;
|
|
}
|
|
const keys = ssbKeys.generate('ed25519'); // primer arranque
|
|
writeSecret(configPath, keys);
|
|
return keys;
|
|
};
|
|
|
|
const hasIdentity = (configPath) =>
|
|
fs.existsSync(secretEncPath(configPath)) || fs.existsSync(legacySecretPath(configPath));
|
|
|
|
module.exports = {
|
|
MAGIC, deriveKEK, wrap, unwrap, isWrapped,
|
|
isConfigured, setPassword, verifyPassword,
|
|
getKEK, setKEK, isUnlocked, lock,
|
|
loadOrCreateSecret, writeSecret, hasIdentity,
|
|
};
|