UNPKG

edge-core-js

Version:

Edge account & wallet management library

822 lines (694 loc) 22.7 kB
import { asMaybe, asObject } from 'cleaners' import { pickMethod, pickPath, } from 'serverlet' import { wasEdgeRepoDump } from '../../types/fake-types' import { asChangeOtpPayload, asChangePasswordPayload, asChangePin2IdPayload, asChangePin2Payload, asChangeRecovery2IdPayload, asChangeRecovery2Payload, asChangeSecretPayload, asChangeUsernamePayload, asChangeVouchersPayload, asCreateKeysPayload, asCreateLoginPayload, asEdgeBox, asEdgeLobbyReply, asEdgeLobbyRequest, asLoginRequestBody, wasLobbyPayload, wasLoginPayload, wasMessagesPayload, wasOtpResetPayload, wasRecovery2InfoPayload, wasUsernameInfoPayload } from '../../types/server-cleaners' import { checkTotp } from '../../util/crypto/hotp' import { verifyData } from '../../util/crypto/verify' import { utf8 } from '../../util/encoding' import { addHiddenProperties, softCat } from '../../util/util' import { userIdSnrp } from '../scrypt/scrypt-selectors' import { makeLoginPayload, makePendingVouchers } from './fake-db' import { cleanRequest, jsonResponse, otpErrorResponse, passwordErrorResponse, payloadResponse, statusCodes, statusResponse } from './fake-responses' // Authentication middleware: ---------------------------------------------- const withApiKey = (server) => async request => { const { json } = request const [body, bodyError] = cleanRequest(asLoginRequestBody, json) if (body == null) return bodyError return await server({ ...request, body, payload: body.data }) } const withValidOtp = server => async request => { const { body, login } = request const { otp, voucherAuth, voucherId } = body // Deactivated OTP is fine: const { otpKey } = login if (otpKey == null) return await server(request) // A valid OTP is good: if (otp != null && checkTotp(otpKey, otp, { spread: 2 })) { return await server(request) } // An approved voucher is good: if (voucherAuth != null && voucherId != null) { const voucher = login.vouchers.find( voucher => voucher.voucherId === voucherId ) if ( voucher != null && voucher.status === 'approved' && verifyData(voucherAuth, voucher.voucherAuth) ) { return await server(request) } } login.otpResetAuth = 'Super secret reset token' const voucher = { activates: new Date('2020-01-01T00:00:00Z'), created: new Date('2020-01-08T00:00:00Z'), deviceDescription: 'A phone', ip: 'localhost', ipDescription: 'here', loginId: login.loginId, status: 'pending', voucherAuth: Uint8Array.from([0xaa, 0xbb]), voucherId: `voucher-${login.vouchers.length}` } login.vouchers.push(voucher) return otpErrorResponse(login, { voucher }) } const handleMissingCredentials = request => statusResponse(statusCodes.invalidRequest) /** * Verifies that the request contains valid v2 authentication. */ const withLogin2 = ( server, fallback = handleMissingCredentials ) => request => { const { db, body } = request const { loginAuth, loginId, passwordAuth, pin2Auth, pin2Id, recovery2Auth, recovery2Id, userId } = body // Token login: if (loginId != null && loginAuth != null) { const login = db.getLoginById(loginId) if (login == null) { return statusResponse(statusCodes.noAccount) } if (login.loginAuth == null || !verifyData(loginAuth, login.loginAuth)) { return passwordErrorResponse(0) } return withValidOtp(server)({ ...request, login }) } // Password login: if (userId != null && passwordAuth != null) { const login = db.getLoginByUserId(userId) if (login == null) { return statusResponse(statusCodes.noAccount) } if ( login.passwordAuth == null || !verifyData(passwordAuth, login.passwordAuth) ) { return passwordErrorResponse(0) } return withValidOtp(server)({ ...request, login }) } // PIN2 login: if (pin2Id != null && pin2Auth != null) { const login = db.getLoginByPin2Id(pin2Id) if (login == null) { return statusResponse(statusCodes.noAccount) } if (login.pin2Auth == null || !verifyData(pin2Auth, login.pin2Auth)) { return passwordErrorResponse(0) } return withValidOtp(server)({ ...request, login }) } // Recovery2 login: if (recovery2Id != null && recovery2Auth != null) { const login = db.getLoginByRecovery2Id(recovery2Id) if (login == null) { return statusResponse(statusCodes.noAccount) } const serverAuth = login.recovery2Auth const clientAuth = recovery2Auth if (serverAuth == null || clientAuth.length !== serverAuth.length) { return passwordErrorResponse(0) } for (let i = 0; i < clientAuth.length; ++i) { if (!verifyData(clientAuth[i], serverAuth[i])) { return passwordErrorResponse(0) } } return withValidOtp(server)({ ...request, login }) } return fallback(request) } // login v2: --------------------------------------------------------------- const loginRoute = withLogin2( // Authenticated version: request => { const { db, login } = request return payloadResponse(wasLoginPayload(makeLoginPayload(db, login))) }, // Fallback version: request => { const { db, json } = request const clean = asLoginRequestBody(json) const { userId, passwordAuth, recovery2Id, recovery2Auth } = clean if (userId != null && passwordAuth == null) { const login = db.getLoginByUserId(userId) if (login == null) { return statusResponse(statusCodes.noAccount) } const { loginId, passwordAuthSnrp = userIdSnrp } = login return payloadResponse( wasUsernameInfoPayload({ loginId, passwordAuthSnrp }) ) } if (recovery2Id != null && recovery2Auth == null) { const login = db.getLoginByRecovery2Id(recovery2Id) if (login == null) { return statusResponse(statusCodes.noAccount) } const { question2Box } = login if (question2Box == null) { return statusResponse(statusCodes.noAccount) } return payloadResponse(wasRecovery2InfoPayload({ question2Box })) } return statusResponse(statusCodes.invalidRequest) } ) function createLogin(request, login) { const { db, json } = request const date = new Date() const [body, bodyError] = cleanRequest(asLoginRequestBody, json) if (body == null) return bodyError const [clean, cleanError] = cleanRequest(asCreateLoginPayload, body.data) if (clean == null) return cleanError const [secret, secretError] = cleanRequest(asChangeSecretPayload, body.data) if (secret == null) return secretError // Do not re-create accounts: if (db.getLoginById(clean.loginId) != null) { return statusResponse(statusCodes.accountExists) } // Set up repos: const keys = asMaybe(asCreateKeysPayload, () => ({ newSyncKeys: [], keyBoxes: [] }))(body.data) for (const syncKey of keys.newSyncKeys) { db.repos.set(syncKey, {}) } // Start building the new database row: const row = { // Required fields: ...clean, ...secret, created: date, keyBoxes: keys.keyBoxes.map(box => ({ ...box, created: date })), vouchers: [], // Optional fields: ...asMaybe(asChangeOtpPayload)(body.data), ...asMaybe(asChangePasswordPayload)(body.data), ...asMaybe(asChangePin2Payload)(body.data), ...asMaybe(asChangeRecovery2Payload)(body.data), ...asMaybe(asChangeUsernamePayload)(body.data) } // Set up the parent/child relationship: if (login != null) { const children = db.getLoginsByParent(login) const appIdExists = children.find(child => child.appId === clean.appId) != null if (appIdExists) { return statusResponse(statusCodes.invalidAppId) } row.parentId = login.loginId } db.insertLogin(row) return statusResponse(statusCodes.created, 'Account created') } const createLoginRoute = withLogin2( request => createLogin(request, request.login), request => createLogin(request) ) const addKeysRoute = withLogin2(request => { const { db, login, payload } = request const date = new Date() const [clean, cleanError] = cleanRequest(asCreateKeysPayload, payload) if (clean == null) return cleanError // Set up repos: for (const syncKey of clean.newSyncKeys) { db.repos.set(syncKey, {}) } login.keyBoxes = softCat( login.keyBoxes, clean.keyBoxes.map(box => ({ ...box, created: date })) ) return statusResponse() }) const changeOtpRoute = withLogin2(request => { const { login, payload } = request const [clean, cleanError] = cleanRequest(asChangeOtpPayload, payload) if (clean == null) return cleanError login.otpKey = clean.otpKey login.otpTimeout = clean.otpTimeout login.otpResetDate = undefined return statusResponse() }) const deleteOtpRoute = withLogin2( // Authenticated version: request => { const { login } = request login.otpKey = undefined login.otpResetAuth = undefined login.otpResetDate = undefined login.otpTimeout = undefined return statusResponse() }, // Fallback version: request => { const { db, json } = request const clean = asLoginRequestBody(json) if (clean.userId == null || clean.otpResetAuth == null) { return statusResponse(statusCodes.invalidRequest) } const login = db.getLoginByUserId(clean.userId) if (login == null) { return statusResponse(statusCodes.noAccount) } if (clean.otpResetAuth !== login.otpResetAuth) { return passwordErrorResponse(0) } const { otpKey, otpTimeout } = login if (otpKey == null || otpTimeout == null) { return statusResponse( statusCodes.invalidRequest, 'OTP not setup for this account.' ) } if (login.otpResetDate == null) { login.otpResetDate = new Date(Date.now() + 1000 * otpTimeout) } return payloadResponse( wasOtpResetPayload({ otpResetDate: login.otpResetDate }) ) } ) const deletePasswordRoute = withLogin2(request => { const { login } = request login.passwordAuth = undefined login.passwordAuthBox = undefined login.passwordAuthSnrp = undefined login.passwordBox = undefined login.passwordKeySnrp = undefined return statusResponse() }) const changePasswordRoute = withLogin2(request => { const { login, payload } = request const [clean, cleanError] = cleanRequest(asChangePasswordPayload, payload) if (clean == null) return cleanError login.passwordAuth = clean.passwordAuth login.passwordAuthBox = clean.passwordAuthBox login.passwordAuthSnrp = clean.passwordAuthSnrp login.passwordBox = clean.passwordBox login.passwordKeySnrp = clean.passwordKeySnrp return statusResponse() }) const deletePin2Route = withLogin2(request => { const { login } = request login.pin2Auth = undefined login.pin2Box = undefined login.pin2Id = undefined login.pin2KeyBox = undefined login.pin2TextBox = undefined return statusResponse() }) const changePin2Route = withLogin2(request => { const { login, payload } = request const [clean, cleanError] = cleanRequest(asChangePin2Payload, payload) if (clean == null) return cleanError login.pin2Auth = clean.pin2Auth login.pin2Box = clean.pin2Box login.pin2Id = clean.pin2Id login.pin2KeyBox = clean.pin2KeyBox login.pin2TextBox = clean.pin2TextBox return statusResponse() }) const deleteRecovery2Route = withLogin2(request => { const { login } = request login.question2Box = undefined login.recovery2Auth = undefined login.recovery2Box = undefined login.recovery2Id = undefined login.recovery2KeyBox = undefined return statusResponse() }) const changeRecovery2Route = withLogin2(request => { const { login, payload } = request const [clean, cleanError] = cleanRequest(asChangeRecovery2Payload, payload) if (clean == null) return cleanError login.question2Box = clean.question2Box login.recovery2Auth = clean.recovery2Auth login.recovery2Box = clean.recovery2Box login.recovery2Id = clean.recovery2Id login.recovery2KeyBox = clean.recovery2KeyBox return statusResponse() }) const secretRoute = withLogin2(request => { const { db, login, payload } = request const [clean, cleanError] = cleanRequest(asChangeSecretPayload, payload) if (clean == null) return cleanError // Do a quick sanity check: if (login.loginAuth != null) { return statusResponse( statusCodes.conflict, 'The secret-key login is already configured' ) } login.loginAuth = clean.loginAuth login.loginAuthBox = clean.loginAuthBox return payloadResponse(wasLoginPayload(makeLoginPayload(db, login))) }) const usernameRoute = withLogin2(async request => { const { db, login, payload } = request const cleanPassword = asMaybe(asChangePasswordPayload)(payload) const cleanPin2 = asMaybe(asChangePin2IdPayload)(payload) const cleanRecovery2 = asMaybe(asChangeRecovery2IdPayload)(payload) const cleanUsername = asMaybe(asChangeUsernamePayload)(payload) // Validate the payload selection: if (login.passwordAuth != null && cleanPassword == null) { return statusResponse( statusCodes.invalidRequest, 'Missing password payload' ) } if (login.pin2Auth != null && cleanPin2 == null) { return statusResponse(statusCodes.invalidRequest, 'Missing pin2Id payload') } if (login.recovery2Auth != null && cleanRecovery2 == null) { return statusResponse( statusCodes.invalidRequest, 'Missing recovery2Id payload' ) } if (login.parentBox == null && cleanUsername == null) { return statusResponse( statusCodes.invalidRequest, 'Missing username payload' ) } // Do we have a password? if (cleanPassword != null) { login.passwordAuth = cleanPassword.passwordAuth login.passwordAuthBox = cleanPassword.passwordAuthBox login.passwordAuthSnrp = cleanPassword.passwordAuthSnrp login.passwordBox = cleanPassword.passwordBox login.passwordKeySnrp = cleanPassword.passwordKeySnrp } // Do we have a PIN? if (cleanPin2 != null) { if (login.pin2Auth == null) { return statusResponse(statusCodes.invalidRequest, 'Login lacks pin2') } const existing = db.getLoginByPin2Id(cleanPin2.pin2Id) if (existing != null) { return statusResponse(statusCodes.conflict) } login.pin2Id = cleanPin2.pin2Id } // Do we have recovery? if (cleanRecovery2 != null) { if (login.recovery2Auth == null) { return statusResponse(statusCodes.invalidRequest, 'Login lacks recovery2') } const existing = db.getLoginByRecovery2Id(cleanRecovery2.recovery2Id) if (existing != null) { return statusResponse(statusCodes.conflict) } login.recovery2Id = cleanRecovery2.recovery2Id } // Are we the root login? if (cleanUsername != null) { if (login.parentBox != null) { return statusResponse( statusCodes.invalidRequest, 'Only top-level logins can have usernames' ) } const existing = db.getLoginByUserId(cleanUsername.userId) if (existing != null) { return statusResponse(statusCodes.conflict) } login.userId = cleanUsername.userId login.userTextBox = cleanUsername.userTextBox } return payloadResponse(wasLoginPayload(makeLoginPayload(db, login))) }) const vouchersRoute = withLogin2(async request => { const { db, login, payload } = request const [clean, cleanError] = cleanRequest(asChangeVouchersPayload, payload) if (clean == null) return cleanError const { approvedVouchers = [], rejectedVouchers = [] } = clean // Let's get our tasks organized: const table = {} for (const id of approvedVouchers) table[id] = 'approved' for (const id of rejectedVouchers) table[id] = 'rejected' // Grab all the rows: for (const voucher of login.vouchers) { if (table[voucher.voucherId] == null) continue voucher.status = table[voucher.voucherId] } return payloadResponse(wasLoginPayload(makeLoginPayload(db, login))) }) // lobby: ------------------------------------------------------------------ const handleMissingLobby = request => statusResponse(statusCodes.noLobby, `Cannot find lobby ${request.lobbyId}`) const withLobby = ( server, fallback = handleMissingLobby ) => request => { const { db, path } = request const lobbyId = path.split('/')[4] const lobby = db.lobbies.get(lobbyId) return lobby != null ? server({ ...request, lobby, lobbyId }) : fallback({ ...request, lobbyId }) } const createLobbyRoute = withLobby( request => statusResponse( statusCodes.accountExists, `Lobby ${request.lobbyId} already exists.` ), request => { const { db, json, lobbyId } = request const [body, bodyError] = cleanRequest(asLoginRequestBody, json) if (body == null) return bodyError const [clean, cleanError] = cleanRequest(asEdgeLobbyRequest, body.data) if (clean == null) return cleanError const { timeout = 600 } = clean const expires = new Date(Date.now() + 1000 * timeout).toISOString() db.lobbies.set(lobbyId, { request: clean, replies: [], expires }) return statusResponse() } ) const updateLobbyRoute = withLobby(request => { const { json, lobby } = request const [body, bodyError] = cleanRequest(asLoginRequestBody, json) if (body == null) return bodyError const [clean, cleanError] = cleanRequest(asEdgeLobbyReply, body.data) if (clean == null) return cleanError lobby.replies.push(clean) return statusResponse() }) const getLobbyRoute = withLobby(request => { const { lobby } = request return payloadResponse(wasLobbyPayload(lobby)) }) const deleteLobbyRoute = withLobby(request => { const { db, lobbyId } = request db.lobbies.delete(lobbyId) return statusResponse() }) // messages: --------------------------------------------------------------- const messagesRoute = request => { const { db, json } = request const [clean, cleanError] = cleanRequest(asLoginRequestBody, json) if (clean == null) return cleanError const { loginIds } = clean if (loginIds == null) return statusResponse(statusCodes.invalidRequest) const out = [] for (const loginId of loginIds) { const login = db.getLoginById(loginId) if (login != null) { out.push({ loginId, otpResetPending: login.otpResetDate != null, pendingVouchers: makePendingVouchers(login), recovery2Corrupt: false }) } } return payloadResponse(wasMessagesPayload(out)) } // sync: ------------------------------------------------------------------- const withRepo = (server) => request => { const { db, path } = request const elements = path.split('/') const syncKey = elements[4] // const hash = elements[5] const repo = db.repos.get(syncKey) if (repo == null) { // This is not the auth server, so we have a different format: return jsonResponse({ msg: 'Hash not found' }, { status: 404 }) } return server({ ...request, repo }) } const storeReadRoute = withRepo(request => { const { repo } = request return jsonResponse({ changes: wasEdgeRepoDump(repo) }) }) const storeUpdateRoute = withRepo(request => { const { json, repo } = request const { changes } = asStoreBody(json) for (const change of Object.keys(changes)) { repo[change] = changes[change] } return jsonResponse({ changes: wasEdgeRepoDump(repo), hash: '1111111111111111111111111111111111111111' }) }) const asStoreBody = asObject({ changes: asObject(asEdgeBox) }) // info: ------------------------------------------------------------------- const infoRoute = request => { return jsonResponse({ infoServers: ['https://info-fake1.edge.app'], syncServers: [ 'https://sync-fake1.edge.app', 'https://sync-fake2.edge.app', 'https://sync-fake3.edge.app' ] }) } // router: ----------------------------------------------------------------- const urls = pickPath( { // Login v2 endpoints: '/api/v2/login/?': pickMethod({ GET: withApiKey(loginRoute), POST: withApiKey(loginRoute) }), '/api/v2/login/create/?': pickMethod({ POST: withApiKey(createLoginRoute), PUT: withApiKey(createLoginRoute) }), '/api/v2/login/keys/?': pickMethod({ POST: withApiKey(addKeysRoute) }), '/api/v2/login/otp/?': pickMethod({ DELETE: withApiKey(deleteOtpRoute), POST: withApiKey(changeOtpRoute), PUT: withApiKey(changeOtpRoute) }), '/api/v2/login/password/?': pickMethod({ DELETE: withApiKey(deletePasswordRoute), POST: withApiKey(changePasswordRoute), PUT: withApiKey(changePasswordRoute) }), '/api/v2/login/pin2/?': pickMethod({ DELETE: withApiKey(deletePin2Route), POST: withApiKey(changePin2Route), PUT: withApiKey(changePin2Route) }), '/api/v2/login/recovery2/?': pickMethod({ DELETE: withApiKey(deleteRecovery2Route), POST: withApiKey(changeRecovery2Route), PUT: withApiKey(changeRecovery2Route) }), '/api/v2/login/secret/?': pickMethod({ POST: withApiKey(secretRoute) }), '/api/v2/login/username/?': pickMethod({ POST: withApiKey(usernameRoute) }), '/api/v2/login/vouchers/?': pickMethod({ POST: withApiKey(vouchersRoute) }), '/api/v2/messages/?': pickMethod({ POST: withApiKey(messagesRoute) }), // Lobby server endpoints: '/api/v2/lobby/[^/]+/?': pickMethod({ DELETE: withApiKey(deleteLobbyRoute), GET: withApiKey(getLobbyRoute), POST: withApiKey(updateLobbyRoute), PUT: withApiKey(createLobbyRoute) }), // Sync server endpoints: '/api/v2/store/[^/]+/?': pickMethod({ GET: storeReadRoute, POST: storeUpdateRoute }), // Info server endpoints: '/v1/edgeServers': pickMethod({ GET: infoRoute }) }, request => statusResponse(statusCodes.notFound, `Unknown API endpoint ${request.path}`) ) /** * Binds the fake server to a particular db instance. */ export function makeFakeServer( db ) { const serveRequest = request => { if (out.offline) throw new Error('Fake network error') const json = request.body.byteLength > 0 ? JSON.parse(utf8.stringify(new Uint8Array(request.body))) : {} return urls({ ...request, db, json }) } const out = addHiddenProperties(serveRequest, { offline: false }) return out }