mirror of https://github.com/xwiki-labs/cryptpad
serverside protocol work for authentication enforcement and configuration
This commit is contained in:
parent
b753a067ac
commit
41e870d3db
|
@ -0,0 +1,337 @@
|
|||
/* globals Buffer */
|
||||
const B32 = require("thirty-two");
|
||||
const OTP = require("notp");
|
||||
const JWT = require("jsonwebtoken");
|
||||
const nThen = require("nthen");
|
||||
const Util = require("../common-util");
|
||||
|
||||
const MFA = require("../storage/mfa");
|
||||
const Sessions = require("../storage/sessions");
|
||||
const Block = require("../storage/block");
|
||||
|
||||
const Commands = module.exports;
|
||||
|
||||
var isString = s => typeof(s) === 'string';
|
||||
|
||||
// basic definition of what we'll accept as an OTP code
|
||||
// exactly six numerical digits
|
||||
var isValidOTP = otp => {
|
||||
return isString(otp) &&
|
||||
// in the future this could be updated to support 8 digits
|
||||
otp.length === 6 &&
|
||||
// \D is non-digit characters, so this tests that it is exclusively numeric
|
||||
!/\D/.test(otp);
|
||||
};
|
||||
|
||||
// we'll only allow users to set up multi-factor auth
|
||||
// for keypairs they control which already have blocks
|
||||
// this check doesn't confirm that their id is valid base64
|
||||
// any attempt relying on this should fail when we can't decode
|
||||
// the id they provided.
|
||||
var isValidBlockId = id => {
|
||||
return id && isString(id) && id.length === 44;
|
||||
};
|
||||
|
||||
// the base32 library can throw when decoding under various conditions.
|
||||
// we have some basic requirements for the length of base32 as well,
|
||||
// so we just do all the validation here. It either returns a buffer
|
||||
// of length 20 or undefined, so the caller can just check whether it's
|
||||
// falsey and otherwise assume it was well-formed
|
||||
// Length === 20 comes from the recommendation of 160 bits of entropy
|
||||
// in RFC4226 (https://www.rfc-editor.org/rfc/rfc4226#section-4)
|
||||
var decode32 = S => {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = B32.decode(S);
|
||||
} catch (err) { return; }
|
||||
if (!(decoded instanceof Buffer) || decoded.length !== 20) { return; }
|
||||
return decoded;
|
||||
};
|
||||
|
||||
var createJWT = function (Env, sessionId, publicKey, cb) {
|
||||
JWT.sign({
|
||||
// this is a custom JWT field (not a standard) - we include a reference to the session
|
||||
// which is used to look up whether it has been revoked.
|
||||
ref: sessionId,
|
||||
// we specify in the token for what resource the token should be valid (their block's public key)
|
||||
sub: Util.escapeKeyCharacters(publicKey),
|
||||
}, Env.bearerSecret, {
|
||||
// token integrity is ensured with HMAC SHA512 with the server's bearerSecret
|
||||
// clients can inspect token parameters, but cannot modify them
|
||||
algorithm: 'HS512',
|
||||
// if you want it to expire you can set this for an arbitrary number of seconds in the future, but I won't assume that for now
|
||||
//expiresIn: (60 * 60 * 24 * 7)),
|
||||
}, function (err, token) {
|
||||
if (err) { return void cb(err); }
|
||||
cb(void 0, token);
|
||||
});
|
||||
};
|
||||
|
||||
// This command allows clients to configure TOTP as a second factor protecting
|
||||
// their login block IFF they:
|
||||
// 1. provide a sufficiently strong TOTP secret
|
||||
// 2. are able to produce a valid OTP code for that secret (indicating that their clock is sufficiently close to ours)
|
||||
// 3. such a login block actually exists
|
||||
// 4. are able to sign an arbitrary message for the login block's public key
|
||||
// 5. have not already configured TOTP protection for this account
|
||||
// (changing to a new secret can be done by disabling and re-enabling TOTP 2FA)
|
||||
const TOTP_SETUP = Commands.TOTP_SETUP = function (Env, body, cb) {
|
||||
const { publicKey, secret, code, contact } = body;
|
||||
|
||||
// the client MUST provide an OTP code of the expected format
|
||||
// this doesn't check if it matches the secret and time, just that it's well-formed
|
||||
if (!isValidOTP(code)) { return void cb("E_INVALID"); }
|
||||
|
||||
// if they provide an (optional) point of contact as a recovery mechanism then it should be a string.
|
||||
// the intent is to allow to specify some side channel for those who inevitably lock themselves out
|
||||
// we should be able to use that to validate their identity.
|
||||
// I don't want to assume email, but limiting its length to 254 (the maximum email length) seems fair.
|
||||
if (contact && (!isString(contact) || contact.length > 254)) { return void cb("INVALID_CONTACT"); }
|
||||
|
||||
// Check that the provided public key is the expected format for a block
|
||||
if (!isValidBlockId(publicKey)) {
|
||||
return void cb("INVALID_KEY");
|
||||
}
|
||||
|
||||
// decode32 checks whether the secret decodes to a sufficiently long buffer
|
||||
var decoded = decode32(secret);
|
||||
if (!decoded) { return void cb('INVALID_SECRET'); }
|
||||
|
||||
// Reject attempts to setup TOTP if a record of their preferences already exists
|
||||
MFA.read(Env, publicKey, function (err) {
|
||||
// There **should be** an error here, because anything else
|
||||
// means that a record already exists
|
||||
// This may need to be adjusted as other methods of MFA are added
|
||||
if (!err) { return void cb("EEXISTS"); }
|
||||
|
||||
// if no MFA settings exist then we expect ENOENT
|
||||
// anything else indicates a problem and should result in rejection
|
||||
if (err.code !== 'ENOENT') { return void cb(err); }
|
||||
try {
|
||||
// allow for 30s of clock drift in either direction
|
||||
// returns an object ({ delta: 0 }) indicating the amount of clock drift
|
||||
// if successful, otherwise `null`
|
||||
var validated = OTP.totp.verify(code, decoded, {
|
||||
window: 1,
|
||||
});
|
||||
if (!validated) { return void cb("INVALID_OTP"); }
|
||||
cb();
|
||||
} catch (err2) {
|
||||
Env.Log.error('TOTP_SETUP_VERIFICATION_ERROR', {
|
||||
error: err2,
|
||||
});
|
||||
return void cb("INTERNAL_ERROR");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// The 'complete' step for TOTP_SETUP will only be called if the client
|
||||
// passed earlier validation and successfully signed the server's challenge.
|
||||
// There's still a little bit more to do and it could still fail.
|
||||
TOTP_SETUP.complete = function (Env, body, cb) {
|
||||
// the OTP code should have already been validated
|
||||
var { publicKey, secret, contact } = body;
|
||||
|
||||
// the device from which they configure MFA settings
|
||||
// is assumed to be safe, so we'll respond with a JWT token
|
||||
// the remainder of the setup is successfully completed.
|
||||
// Otherwise they would have to reauthenticate.
|
||||
// The session id is used as a reference to this particular session.
|
||||
const sessionId = Sessions.randomId();
|
||||
var token;
|
||||
nThen(function (w) {
|
||||
// confirm that the block exists
|
||||
Block.check(Env, publicKey, w(function (err) {
|
||||
if (err) {
|
||||
Env.Log.error("TOTP_SETUP_NO_BLOCK", {
|
||||
publicKey,
|
||||
});
|
||||
w.abort();
|
||||
return void cb("NO_BLOCK");
|
||||
}
|
||||
// otherwise the block exists, continue
|
||||
}));
|
||||
}).nThen(function (w) {
|
||||
// store the data you'll need in the future
|
||||
var data = {
|
||||
method: 'TOTP', // specify this so it's easier to add other methods later?
|
||||
secret: secret, // the 160 bit, base32-encoded secret that is used for OTP validation
|
||||
creation: new Date(), // the moment at which the MFA was configured
|
||||
};
|
||||
|
||||
if (isString(contact)) {
|
||||
// 'contact' is an arbitary (and optional) string for manual recovery from 2FA auth fails
|
||||
// it should already be validated
|
||||
data.contact = contact;
|
||||
}
|
||||
|
||||
// We attempt to store a record of the above preferences
|
||||
// if it fails then we abort and inform the client of an error.
|
||||
MFA.write(Env, publicKey, JSON.stringify(data), w(function (err) {
|
||||
if (err) {
|
||||
w.abort();
|
||||
Env.Log.error("TOTP_SETUP_STORAGE_FAILURE", {
|
||||
publicKey: publicKey,
|
||||
error: err,
|
||||
});
|
||||
return void cb('STORAGE_FAILURE');
|
||||
}
|
||||
// otherwise continue
|
||||
}));
|
||||
}).nThen(function (w) {
|
||||
// generate a bearer token and store it
|
||||
createJWT(Env, sessionId, publicKey, w(function (err, _token) {
|
||||
if (err) {
|
||||
// we have already stored the MFA data, which will cause access to the resource to be restricted to the provided TOTP secret.
|
||||
// we attempt to create a session as a matter of convenience - so if it fails
|
||||
// that just means they'll be forced to authenticate
|
||||
Env.Log.error("TOTP_SETUP_JWT_SIGN_ERROR", {
|
||||
error: err,
|
||||
publicKey: publicKey,
|
||||
});
|
||||
return void cb('TOKEN_ERROR');
|
||||
}
|
||||
token = _token;
|
||||
}));
|
||||
}).nThen(function (w) {
|
||||
// store the token
|
||||
Sessions.write(Env, publicKey, sessionId, token, w(function (err) {
|
||||
if (err) {
|
||||
// again, if there's a failure here the user should automatically
|
||||
// be forced to reauthenticate because their block is protected
|
||||
// but they will not have a valid JWT allowing them to access it
|
||||
Env.Log.error("TOTP_SETUP_SESSION_WRITE", {
|
||||
error: err,
|
||||
publicKey: publicKey,
|
||||
sessionId: sessionId,
|
||||
});
|
||||
w.abort();
|
||||
return void cb("SESSION_WRITE_ERROR");
|
||||
}
|
||||
// else continue
|
||||
}));
|
||||
}).nThen(function () {
|
||||
// respond with the stored token that they can now use to authenticate
|
||||
cb(void 0, {
|
||||
bearer: token,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// This command is somewhat simpler than TOTP_SETUP
|
||||
// Issue a client a JWT which will allow them to access a login block IFF:
|
||||
// 1. That login block exists
|
||||
// 2. That login block is protected by TOTP 2FA
|
||||
// 3. They can produce a valid OTP for that block's TOTP secret
|
||||
// 4. They can sign for the block's public key
|
||||
const validate = Commands.TOTP_VALIDATE = function (Env, body, cb) {
|
||||
var { publicKey, code } = body;
|
||||
|
||||
// they must provide a valid OTP code
|
||||
if (!isValidOTP(code)) { return void cb('E_INVALID'); }
|
||||
|
||||
// they must provide a valid block public key
|
||||
if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
|
||||
|
||||
var secret;
|
||||
nThen(function (w) {
|
||||
// check that there is an MFA configuration for the given account
|
||||
MFA.read(Env, publicKey, w(function (err, content) {
|
||||
if (err) {
|
||||
w.abort();
|
||||
Env.Log.error('TOTP_VALIDATE_MFA_READ', {
|
||||
error: err,
|
||||
publicKey: publicKey,
|
||||
});
|
||||
return void cb('NO_MFA_CONFIGURED');
|
||||
}
|
||||
|
||||
var parsed = Util.tryParse(content);
|
||||
|
||||
if (!parsed) {
|
||||
w.abort();
|
||||
return void cb("INVALID_CONFIGURATION");
|
||||
}
|
||||
|
||||
secret = parsed.secret;
|
||||
}));
|
||||
}).nThen(function () {
|
||||
let decoded = decode32(secret);
|
||||
if (!decoded) {
|
||||
Env.Log.error("TOTP_VALIDATE_INVALID_SECRET", {
|
||||
publicKey, // log the public key so the admin can investigate further
|
||||
// don't log the problematic secret directly as
|
||||
// logs are likely to be pasted in random places
|
||||
});
|
||||
return void cb("E_INVALID_SECRET");
|
||||
}
|
||||
|
||||
// validate the code
|
||||
var validated = OTP.totp.verify(code, decoded, {
|
||||
window: 1,
|
||||
});
|
||||
|
||||
if (!validated) {
|
||||
// I won't worry about logging these OTPs as they shouldn't leak any useful information
|
||||
Env.Log.error("TOTP_VALIDATE_BAD_OTP", {
|
||||
code,
|
||||
});
|
||||
return void cb("INVALID_OTP");
|
||||
}
|
||||
|
||||
// call back to indicate that their request was well-formed and valid
|
||||
cb();
|
||||
});
|
||||
};
|
||||
|
||||
validate.complete = function (Env, body, cb) {
|
||||
/*
|
||||
if they are here then they:
|
||||
|
||||
1. have a valid block configured with TOTP-based 2FA
|
||||
2. were able to provide a valid TOTP for that block's secret
|
||||
3. were able to sign their messages for the block's public key
|
||||
|
||||
So, we should:
|
||||
|
||||
1. instanciate a session for them by generating and storing a token for their public key
|
||||
2. send them the token
|
||||
|
||||
*/
|
||||
var { publicKey } = body;
|
||||
|
||||
const sessionId = Sessions.randomId();
|
||||
|
||||
var token;
|
||||
nThen(function (w) {
|
||||
createJWT(Env, sessionId, publicKey, w(function (err, _token) {
|
||||
if (err) {
|
||||
Env.Log.error("TOTP_VALIDATE_JWT_SIGN_ERROR", {
|
||||
error: err,
|
||||
publicKey: publicKey,
|
||||
});
|
||||
return void cb("TOKEN_ERROR");
|
||||
}
|
||||
token = _token;
|
||||
}));
|
||||
}).nThen(function (w) {
|
||||
// store the token
|
||||
Sessions.write(Env, publicKey, sessionId, token, w(function (err) {
|
||||
if (err) {
|
||||
Env.Log.error("TOTP_VALIDATE_SESSION_WRITE", {
|
||||
error: err,
|
||||
publicKey: publicKey,
|
||||
sessionId: sessionId,
|
||||
});
|
||||
w.abort();
|
||||
return void cb("SESSION_WRITE_ERROR");
|
||||
}
|
||||
// else continue
|
||||
}));
|
||||
}).nThen(function () {
|
||||
cb(void 0, {
|
||||
bearer: token,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,313 @@
|
|||
var Nacl = require("tweetnacl/nacl-fast");
|
||||
var Util = require('./common-util.js');
|
||||
|
||||
var Challenge = require("./storage/challenge.js");
|
||||
// C.read(Env, id, cb)
|
||||
// C.write(Env,id, data, cb)
|
||||
// C.delete(Env, id, cb)
|
||||
|
||||
|
||||
/*
|
||||
The API for command definition consists of two stages:
|
||||
|
||||
Clients first send a command and its associated parameters.
|
||||
The server validates that the command is supported, and that
|
||||
the provided parameters are valid. If it fails validation for any reason,
|
||||
the server responds with an error and the protocol is aborted.
|
||||
|
||||
COMMANDS[COMMAND_NAME] = function (Env, body, cb) {
|
||||
// inspect parameters in the request body
|
||||
if (!body.essential_parameter) {
|
||||
return void cb('NO');
|
||||
}
|
||||
cb();
|
||||
};
|
||||
|
||||
Commands whose parameters are successfully validated
|
||||
have those parameters stored on the disk (or a relational DB in the future).
|
||||
The server then requests that the client sign their well-formulated
|
||||
command along with a server-generated transaction id ('txid': randomized to prevent replays)
|
||||
and a date (so that it can ensure that the client responds within a reasonable window.
|
||||
|
||||
Clients then respond with a txid and a cryptographic signature
|
||||
which matches the parameters of the command. The server loads the command
|
||||
with the corresponding txid, checks that it was signed within a reasonable time window,
|
||||
validates the signature, and attempts to complete the command's execution:
|
||||
|
||||
COMMAND[COMMAND_NAME].complete = function (Env, body, cb) {
|
||||
doAThing(function (err, values) {
|
||||
if (err) {
|
||||
// Log the error and respond that the command was not successful
|
||||
return void cb("SORRY_BUT_IM_NOT_OK");
|
||||
}
|
||||
cb(void 0, {
|
||||
arbitrary: values,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
In this second stage the protocol can be aborted if the client has done something wrong:
|
||||
(ie. if it did not produce a valid signature for the command)
|
||||
or it can can fail because the server was not able to complete the requested task
|
||||
(ie. because of an I/O error or because an error was thrown and caught).
|
||||
|
||||
It is intended that the server will respond with an appropriate error if
|
||||
the request cannot be completed, and it will respond OK if everything completed successfully.
|
||||
|
||||
*/
|
||||
|
||||
var COMMANDS = {};
|
||||
|
||||
// Methods allowing clients to configure Time-based One-Time Passwords for their login-block,
|
||||
// and to authenticate new sessions once a TOTP secret has been associated with their account,
|
||||
const TOTP = require("./challenge-commands/totp.js");
|
||||
COMMANDS.TOTP_SETUP = TOTP.TOTP_SETUP;
|
||||
COMMANDS.TOTP_VALIDATE = TOTP.TOTP_VALIDATE;
|
||||
|
||||
var randomToken = () => Nacl.util.encodeBase64(Nacl.randomBytes(24)).replace(/\//g, '-');
|
||||
|
||||
// this function handles the first stage of the protocol
|
||||
// (the server's validation of the client's request and the generation of its challenge)
|
||||
var handleCommand = function (Env, req, res) {
|
||||
var body = req.body;
|
||||
var command = body.command;
|
||||
|
||||
// reject if the command does not have a corresponding function
|
||||
if (typeof(COMMANDS[command]) !== 'function') {
|
||||
Env.Log.error('CHALLENGE_UNSUPPORTED_COMMAND', command);
|
||||
return void res.status(500).json({
|
||||
error: 'invalid command',
|
||||
});
|
||||
}
|
||||
|
||||
var publicKey = body.publicKey;
|
||||
// reject if they did not provide a valid public key
|
||||
if (!publicKey || typeof(publicKey) !== 'string' || publicKey.length !== 44) {
|
||||
Env.Log.error('CHALLENGE_INVALID_KEY', publicKey);
|
||||
return void res.status(500).json({
|
||||
error: 'Invalid key',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
COMMANDS[command](Env, body, function (err) {
|
||||
if (err) {
|
||||
Env.Log.error('CHALLENGE_COMMAND_EXECUTION_ERROR', {
|
||||
body: body,
|
||||
error: err,
|
||||
});
|
||||
// errors returned from commands are passed back to the client
|
||||
// as a weak precaution, we try to only send an error's message
|
||||
// if one exists. This makes it less likely that we'll respond with any
|
||||
// sensitive information in a stack trace. Ideally functions should
|
||||
// only return error messages or codes in the form of a string or number,
|
||||
// but mistakes happen.
|
||||
return void res.status(500).json({
|
||||
error: (err && err.message) || err,
|
||||
});
|
||||
}
|
||||
|
||||
var txid = randomToken();
|
||||
var date = new Date().toISOString();
|
||||
|
||||
var copy = Util.clone(body);
|
||||
copy.txid = txid;
|
||||
copy.date = date;
|
||||
|
||||
// Write the command and challenge to disk, because the challenge protocol
|
||||
// is interactive and the subsequent response might be handled by a different http worker
|
||||
// this makes it so we can avoid holding state in memory
|
||||
Challenge.write(Env, txid, JSON.stringify(copy), function (err) {
|
||||
if (err) {
|
||||
Env.Log.error('CHALLENGE_WRITE_ERROR', err);
|
||||
return void res.status(500).json({
|
||||
// arbitrary error message, only intended for debugging
|
||||
error: 'Internal server error 6250',
|
||||
});
|
||||
}
|
||||
// respond with challenge parameters
|
||||
return void res.status(200).json({
|
||||
txid: txid,
|
||||
date: date,
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
Env.Log.error("CHALLENGE_COMMAND_THROWN_ERROR", {
|
||||
error: err,
|
||||
});
|
||||
return void res.status(500).json({
|
||||
// arbitrary error message, only intended for debugging
|
||||
error: 'Internal server error 7692',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// this function handles the second stage of the protocol
|
||||
// (the client's response to the server's challenge)
|
||||
var handleResponse = function (Env, req, res) {
|
||||
var body = req.body;
|
||||
|
||||
if (Object.keys(body).some(k => !/(sig|txid)/.test(k))) {
|
||||
Env.Log.error("CHALLENGE_RESPONSE_DEBUGGING", body);
|
||||
// we expect the response to only have two keys
|
||||
// if any more are present then the response is malformed
|
||||
return void res.status(500).json({
|
||||
error: 'extraneous parameters',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// transaction ids are issued to the client by the server
|
||||
// they allow it to recall the full details of the challenge
|
||||
// to which the client is responding
|
||||
var txid = body.txid;
|
||||
|
||||
// if no txid is present, then the server can't look up the corresponding challenge
|
||||
// the response is definitely malformed, so reject it.
|
||||
// Additionally, we expect txids to be 32 characters long (24 Uint8s as base64)
|
||||
// reject txids of any other length
|
||||
if (!txid || typeof(txid) !== 'string' || txid.length !== 32) {
|
||||
Env.Log.error('CHALLENGE_RESPONSE_BAD_TXID', body);
|
||||
return void res.status(500).json({
|
||||
error: "Invalid txid",
|
||||
});
|
||||
}
|
||||
|
||||
var sig = body.sig;
|
||||
if (!sig || typeof(sig) !== 'string' || sig.length !== 88) {
|
||||
Env.Log.error("CHALLENGE_RESPONSE_BAD_SIG", body);
|
||||
return void res.status(500).json({
|
||||
error: "Missing signature",
|
||||
});
|
||||
}
|
||||
|
||||
Challenge.read(Env, txid, function (err, text) {
|
||||
if (err) {
|
||||
Env.Log.error("CHALLENGE_READ_ERROR", {
|
||||
txid: txid,
|
||||
error: err,
|
||||
});
|
||||
return void res.status(500).json({
|
||||
error: "Unexpected response",
|
||||
});
|
||||
}
|
||||
|
||||
// garbage collection can clean this up later
|
||||
Challenge.delete(Env, txid, function (err) {
|
||||
if (err) {
|
||||
Env.Log.error("CHALLENGE_DELETION_ERROR", {
|
||||
txid: txid,
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
var json = Util.tryParse(text);
|
||||
|
||||
if (!json) {
|
||||
Env.Log.error("CHALLENGE_PARSE_ERROR", {
|
||||
txid: txid,
|
||||
});
|
||||
return void res.status(500).json({
|
||||
error: "Internal server error 129",
|
||||
});
|
||||
}
|
||||
|
||||
var publicKey = json.publicKey;
|
||||
if (!publicKey || typeof(publicKey) !== 'string') {
|
||||
// This shouldn't happen, as we expect that the server
|
||||
// will have validated the key to an extent before storing the challenge
|
||||
Env.Log.error('CHALLENGE_INVALID_PUBLICKEY', {
|
||||
publicKey: publicKey,
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: "Invalid public key",
|
||||
});
|
||||
}
|
||||
|
||||
var action;
|
||||
try {
|
||||
action = COMMANDS[json.command].complete;
|
||||
} catch (err2) {}
|
||||
|
||||
if (typeof(action) !== 'function') {
|
||||
Env.Log.error("CHALLENGE_RESPONSE_ACTION_NOT_IMPLEMENTED", json.command);
|
||||
return res.status(501).json({
|
||||
error: 'Not implemented',
|
||||
});
|
||||
}
|
||||
|
||||
var u8_toVerify,
|
||||
u8_sig,
|
||||
u8_publicKey;
|
||||
|
||||
try {
|
||||
u8_toVerify = Nacl.util.decodeUTF8(text);
|
||||
u8_sig = Nacl.util.decodeBase64(sig);
|
||||
u8_publicKey = Nacl.util.decodeBase64(publicKey);
|
||||
} catch (err3) {
|
||||
Env.Log.error('CHALLENGE_RESPONSE_DECODING_ERROR', {
|
||||
text: text,
|
||||
sig: sig,
|
||||
publicKey: publicKey,
|
||||
error: err3,
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: "decoding error"
|
||||
});
|
||||
}
|
||||
|
||||
// validate the response
|
||||
var success = Nacl.sign.detached.verify(u8_toVerify, u8_sig, u8_publicKey);
|
||||
if (success !== true) {
|
||||
Env.Log.error("CHALLENGE_RESPONSE_SIGNATURE_FAILURE", {
|
||||
publicKey,
|
||||
});
|
||||
return void res.status(500).json({
|
||||
error: 'Failed signature validation',
|
||||
});
|
||||
}
|
||||
|
||||
// execute the command
|
||||
action(Env, json, function (err, content) {
|
||||
if (err) {
|
||||
Env.Log.error("CHALLENGE_RESPONSE_ACTION_ERROR", {
|
||||
error: err,
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: 'Execution error',
|
||||
});
|
||||
}
|
||||
res.status(200).json(content);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
module.exports.handle = function (Env, req, res /*, next */) {
|
||||
var body = req.body;
|
||||
// we expect that the client has posted some JSON data
|
||||
if (!body) {
|
||||
return void res.status(500).json({
|
||||
error: 'invalid request',
|
||||
});
|
||||
}
|
||||
|
||||
// we only expect responses to challenges to have a 'txid' attribute
|
||||
// further validation is performed in handleResponse
|
||||
if (body.txid) {
|
||||
return void handleResponse(Env, req, res);
|
||||
}
|
||||
|
||||
// we only expect initial requests to have a 'command' attribute
|
||||
// further validation is performed in handleCommand
|
||||
if (body.command) {
|
||||
return void handleCommand(Env, req, res);
|
||||
}
|
||||
|
||||
// if a request is neither a command nor a response, then reject it with an error
|
||||
res.status(500).json({
|
||||
error: 'invalid request',
|
||||
});
|
||||
};
|
|
@ -6,11 +6,17 @@ const Fs = require("node:fs");
|
|||
const nThen = require("nthen");
|
||||
const Util = require("./common-util");
|
||||
const Logger = require("./log");
|
||||
const AuthCommands = require("./http-commands");
|
||||
const JWT = require("jsonwebtoken");
|
||||
const MFA = require("./storage/mfa");
|
||||
const Sessions = require("./storage/sessions");
|
||||
|
||||
const DEFAULT_QUERY_TIMEOUT = 5000;
|
||||
const PID = process.pid;
|
||||
|
||||
var Env = JSON.parse(process.env.Env);
|
||||
const response = Util.response(function (errLabel, info) {
|
||||
if (!Env.Log) { return; }
|
||||
Env.Log.error(errLabel, info);
|
||||
});
|
||||
|
||||
|
@ -47,10 +53,10 @@ Logger.levels.forEach(level => {
|
|||
|
||||
const EVENTS = {};
|
||||
|
||||
var Env = JSON.parse(process.env.Env);
|
||||
EVENTS.ENV_UPDATE = function (data /*, cb */) {
|
||||
try {
|
||||
Env = JSON.parse(data);
|
||||
Env.Log = Log;
|
||||
} catch (err) {
|
||||
Log.error('HTTP_WORKER_ENV_UPDATE', Util.serializeError(err));
|
||||
}
|
||||
|
@ -241,9 +247,6 @@ app.use(function (req, res, next) {
|
|||
app.use(Express.static(Path.resolve('./customize/www')));
|
||||
app.use(Express.static(Path.resolve('./www')));
|
||||
|
||||
// FIXME I think this is a regression caused by a recent PR
|
||||
// correct this hack without breaking the contributor's intended behaviour.
|
||||
|
||||
var mainPages = Env.mainPages || Default.mainPages();
|
||||
var mainPagePattern = new RegExp('^\/(' + mainPages.join('|') + ').html$');
|
||||
app.get(mainPagePattern, Express.static('./customize'));
|
||||
|
@ -253,7 +256,175 @@ app.use("/blob", Express.static(Path.resolve(Env.paths.blob), {
|
|||
maxAge: Env.DEV_MODE? "0d": "365d"
|
||||
}));
|
||||
|
||||
// XXX update atime?
|
||||
app.use('/block/', function (req, res, next) {
|
||||
var parsed = Path.parse(req.url);
|
||||
var name = parsed.name;
|
||||
// block access control only applies to files
|
||||
// identified by base64-encoded public keys
|
||||
// skip everything else, ie. /block/placeholder.txt
|
||||
if (typeof(name) !== 'string' || name.length !== 44) {
|
||||
return void next();
|
||||
}
|
||||
|
||||
var authorization = req.headers.authorization;
|
||||
|
||||
var mfa_params, jwt_payload;
|
||||
nThen(function (w) {
|
||||
// First, check whether the block id in question has any MFA settings stored
|
||||
MFA.read(Env, name, w(function (err, content) {
|
||||
// ENOENT means there are no settings configured
|
||||
// it could be a 404 or an existing block without MFA protection
|
||||
// in either case you can abort and fall through
|
||||
// allowing the static webserver to handle either case
|
||||
if (err && err.code === 'ENOENT') {
|
||||
w.abort();
|
||||
return void next();
|
||||
}
|
||||
|
||||
// we're not expecting other errors. the sensible thing is to fail
|
||||
// closed - meaning assume some protection is in place but that
|
||||
// the settings couldn't be loaded for some reason. block access
|
||||
// to the resource, logging for the admin and responding to the client
|
||||
// with a vague error code
|
||||
if (err) {
|
||||
Log.error('GET_BLOCK_METADATA', err);
|
||||
return void res.status(500).json({
|
||||
code: 500,
|
||||
error: "UNEXPECTED_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
// Otherwise, some settings were loaded correctly.
|
||||
// We're expecting stringified JSON, so try to parse it.
|
||||
// Log and respond with an error again if this fails.
|
||||
// If it parses successfully then fall through to the next block.
|
||||
try {
|
||||
mfa_params = JSON.parse(content);
|
||||
} catch (err2) {
|
||||
w.abort();
|
||||
Log.error("INVALID_BLOCK_METADATA", err2);
|
||||
return res.status(500).json({
|
||||
code: 500,
|
||||
error: "UNEXPECTED_ERROR",
|
||||
});
|
||||
}
|
||||
}));
|
||||
}).nThen(function (w) {
|
||||
// We should only be able to reach this logic
|
||||
// if we successfully loaded and parsed some JSON
|
||||
// representing the user's MFA settings.
|
||||
|
||||
// Failures at this point relate to insufficient or incorrect authorization.
|
||||
// This function standardizes how we reject such requests.
|
||||
|
||||
// So far the only additional factor which is supported is TOTP.
|
||||
// We specify what the method is to allow for future alternatives
|
||||
// and inform the client so they can determine how to respond
|
||||
// "401" means "Unauthorized"
|
||||
var no = function () {
|
||||
w.abort();
|
||||
res.status(401).json({
|
||||
method: mfa_params.method,
|
||||
code: 401
|
||||
});
|
||||
};
|
||||
|
||||
// if you are here it is because this block is protected by MFA.
|
||||
// they will need to provide a JSON Web Token, so we can reject them outright
|
||||
// if one is not present in their authorization header
|
||||
if (!authorization) { return void no(); }
|
||||
|
||||
// The authorization header should be of the form
|
||||
// "Authorization: Bearer <JWT>"
|
||||
// We can reject the request if it is malformed.
|
||||
let token = authorization.replace(/^Bearer\s+/, '').trim();
|
||||
if (!token) { return void no(); }
|
||||
|
||||
// Otherwise we attempt to validate the token
|
||||
// Successful validation implies that the token was issued by the server
|
||||
// since only the server should possess the current bearer secret (unless it has leaked).
|
||||
|
||||
// It is still possible that the token is not valid for this particular resource,
|
||||
// so the algorithm (HMAC SHA512) only asserts its integrity, not its validity.
|
||||
JWT.verify(token, Env.bearerSecret, {
|
||||
algorithm: 'HS512',
|
||||
}, w(function (err, payload) {
|
||||
if (err) {
|
||||
// the token could not be validated for some reason.
|
||||
// it might have expired, the server might have rotated secrets,
|
||||
// it might not be well-formed, etc.
|
||||
// log and respond.
|
||||
Log.info('INVALID_JWT', {
|
||||
error: err,
|
||||
token: token,
|
||||
});
|
||||
return void no();
|
||||
}
|
||||
|
||||
// Now that we have the payload we can inspect its properties
|
||||
// and reject anything which is obviously wrong without requiring
|
||||
// any async I/O
|
||||
|
||||
// Tokens are issued with a "reference" - a random id which is
|
||||
// used alongside the block id to look up whether a given session
|
||||
// is still valid
|
||||
|
||||
// reject if it does not provide a lookup reference
|
||||
if (typeof(payload.ref) !== 'string') {
|
||||
Log.error("JWT_NO_REFERENCE", payload);
|
||||
return void no();
|
||||
}
|
||||
|
||||
// A JWT can optionally indicate a finite lifetime.
|
||||
// reject if it's too old
|
||||
if (payload.exp && ((+new Date()) > payload.exp)) {
|
||||
Log.error("JWT_EXPIRED", payload);
|
||||
return void no();
|
||||
}
|
||||
|
||||
// A JWT indicates the subject (the block id) for which it is valid
|
||||
// reject if it does not match the block the client is trying to access
|
||||
if (payload.sub !== name) {
|
||||
Log.error("JWT_SUBJECT_MISMATCH", payload);
|
||||
return void no();
|
||||
}
|
||||
|
||||
// otherwise, it seems basically correct.
|
||||
Log.verbose("VALID_JWT", payload);
|
||||
|
||||
// remember the payload for subsequent asynchronous checks
|
||||
jwt_payload = payload;
|
||||
}));
|
||||
}).nThen(function () {
|
||||
// Finally, even if the JWT itself seems valid, the database
|
||||
// is the final authority as to whether the session is still valid,
|
||||
// as it might have been revoked
|
||||
Sessions.read(Env, name, jwt_payload.ref, function (err /*, content */) {
|
||||
if (err) {
|
||||
Log.error('JWT_SESSION_READ_ERROR', err);
|
||||
return res.status(401).json({
|
||||
method: mfa_params.method,
|
||||
code: 401,
|
||||
});
|
||||
}
|
||||
|
||||
// we could also check whether the content of the file matches the token,
|
||||
// but clients don't have any influence over the reference and can only
|
||||
// request to create tokens that are scoped to a public key they control.
|
||||
// I don' think there's any practical benefit to such a check.
|
||||
|
||||
// So, interpret the existence of a file in that location as the continued
|
||||
// validity of the session. Fall through and let the built-in webserver
|
||||
// handle the 404 or serving the file.
|
||||
next();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// TODO this would be a good place to update a block's atime
|
||||
// in a manner independent of the filesystem. ie. for detecting and archiving
|
||||
// inactive accounts in a way that will not be invalidated by other forms of access
|
||||
// like filesystem backups.
|
||||
app.use("/block", Express.static(Path.resolve(Env.paths.block), {
|
||||
maxAge: "0d",
|
||||
}));
|
||||
|
@ -423,6 +594,13 @@ app.get('/api/profiling', function (req, res) {
|
|||
});
|
||||
});
|
||||
|
||||
// This endpoint handles authenticated RPCs over HTTP
|
||||
// via an interactive challenge-response protocol
|
||||
app.use(Express.json());
|
||||
app.post('/api/auth', function (req, res, next) {
|
||||
AuthCommands.handle(Env, req, res, next);
|
||||
});
|
||||
|
||||
app.use(function (req, res /*, next */) {
|
||||
if (/^(\/favicon\.ico\/|.*\.js\.map)$/.test(req.url)) {
|
||||
// ignore common 404s
|
||||
|
@ -461,3 +639,5 @@ nThen(function (w) {
|
|||
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
|
Loading…
Reference in New Issue