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:
parent
ea5f5cbcc2
commit
851042e576
4 changed files with 294 additions and 5 deletions
|
|
@ -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
129
src/models/keylock.js
Normal 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,
|
||||
};
|
||||
|
|
@ -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' };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue