keylock: password-based at-rest encryption

- 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).
This commit is contained in:
s1to 2026-07-09 09:56:30 +02:00
parent ea5f5cbcc2
commit 851042e576
4 changed files with 294 additions and 5 deletions

View file

@ -1,6 +1,7 @@
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const keylock = require('./keylock');
const SENSITIVE_FIELDS = [
'title', 'description', 'location', 'price', 'salary', 'options', 'votes',
@ -45,7 +46,14 @@ module.exports = (configPath, namespace = 'tribes') => {
const loadKeyring = () => {
try {
keyring = JSON.parse(fs.readFileSync(keyringPath, 'utf8'));
const raw = fs.readFileSync(keyringPath);
if (keylock.isWrapped(raw)) {
if (!keylock.getKEK()) throw new Error('keyring cifrado pero keylock bloqueado');
keyring = JSON.parse(keylock.unwrap(raw).toString('utf8'));
} else {
keyring = JSON.parse(raw.toString('utf8'));
if (keylock.getKEK()) { try { saveKeyring(); } catch (_) {} }
}
try { fs.chmodSync(keyringPath, 0o600); } catch (_) {}
} catch (e) {
if (e.code !== 'ENOENT') throw e;
@ -55,8 +63,11 @@ module.exports = (configPath, namespace = 'tribes') => {
};
const saveKeyring = () => {
const json = JSON.stringify(keyring, null, 2);
const kek = keylock.getKEK();
const out = kek ? keylock.wrap(Buffer.from(json, 'utf8'), kek) : Buffer.from(json, 'utf8');
const tmp = keyringPath + '.tmp.' + process.pid + '.' + Date.now();
fs.writeFileSync(tmp, JSON.stringify(keyring, null, 2), { encoding: 'utf8', mode: 0o600 });
fs.writeFileSync(tmp, out, { mode: 0o600 });
fs.renameSync(tmp, keyringPath);
try { fs.chmodSync(keyringPath, 0o600); } catch (_) {}
};

129
src/models/keylock.js Normal file
View file

@ -0,0 +1,129 @@
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,
};

View file

@ -11,9 +11,14 @@ const i = argv.indexOf('--');
const conf = argv.slice(i + 1);
const cliArgs = ~i ? argv.slice(0, i) : argv;
let config = Config('ssb', minimist(conf));
const override = minimist(conf);
if (global.__OASIS_KEYS__) override.keys = global.__OASIS_KEYS__;
let config = Config('ssb', override);
config = { ...config, ...configData };
if (global.__OASIS_KEYS__) config.keys = global.__OASIS_KEYS__;
const debug = process.argv.includes('--debug') || process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
if (debug) {
config.logging = { ...(config.logging || {}), level: 'debug' };