Add U2F support

This commit is contained in:
Jonas Lochmann
2022-09-22 08:47:06 +02:00
parent 04aa2ce517
commit 613776cbf9
64 changed files with 2501 additions and 134 deletions
+14 -54
View File
@@ -16,10 +16,10 @@
*/
import * as Sequelize from 'sequelize'
import { createDecipheriv, createPrivateKey, createPublicKey, diffieHellman } from 'crypto'
import { createDecipheriv } from 'crypto'
import { Database } from '../../database'
import { calculateExpireTime } from '../../database/devicedhkey'
import { isVersionId } from '../../util/token'
import { getSharedSecret, SharedSecretException } from './shared-secret'
export async function decrypt({
database, transaction, familyId, deviceId, encryptedData, authData
@@ -43,61 +43,24 @@ export async function decrypt({
if (!isVersionId(keyId)) throw new KeyNotFoundDecryptException('invalid key id')
const databaseKeyEntry = await database.deviceDhKey.findOne({
where: {
familyId,
deviceId,
version: keyId
},
transaction
})
if (!databaseKeyEntry) throw new KeyNotFoundDecryptException('private key not found')
if (databaseKeyEntry.expireAt === null) {
databaseKeyEntry.expireAt = calculateExpireTime(BigInt(Date.now())).toString(10)
await databaseKeyEntry.save({ transaction })
} else {
if (BigInt(databaseKeyEntry.expireAt) < BigInt(Date.now())) throw new KeyExpiredDecryptException()
}
const privateKey = (() => {
const sharedSecret = await (async () => {
try {
return createPrivateKey({
key: databaseKeyEntry.privateKey,
format: 'der',
type: 'pkcs8'
return getSharedSecret({
database,
transaction,
familyId,
deviceId,
keyId,
otherPublicKey
})
} catch (ex) {
throw new MalformedPrivateKeyException()
}
})()
const decodedOtherPublicKey = (() => {
try {
return createPublicKey({
key: otherPublicKey,
format: 'der',
type: 'spki'
})
} catch (ex) {
throw new MalformedPublicKeyException()
}
})()
const sharedSecret = (() => {
try {
return diffieHellman({
privateKey,
publicKey: decodedOtherPublicKey
})
} catch (ex) {
throw new MalformedNoMatchingKeysException()
if (ex instanceof SharedSecretException) throw new SharedSecretDecryptException(ex)
throw ex
}
})()
try {
const decipher = createDecipheriv('aes-128-gcm', sharedSecret.slice(0, 16), ivAndEncrypted.slice(0, 12), {
const decipher = createDecipheriv('aes-128-gcm', sharedSecret.sharedSecret.slice(0, 16), ivAndEncrypted.slice(0, 12), {
authTagLength: 16
})
@@ -116,10 +79,7 @@ export async function decrypt({
}
export class DecryptException extends Error {}
class SharedSecretDecryptException extends DecryptException { constructor(cause: Error) { super(cause.message) } }
class MalformedDataDecryptException extends DecryptException { constructor(message: string) { super('malformed data: ' + message) } }
class MalformedPrivateKeyException extends DecryptException { constructor() { super('private key') } }
class MalformedPublicKeyException extends DecryptException { constructor() { super('public key') } }
class MalformedNoMatchingKeysException extends DecryptException { constructor() { super('no matching keys') } }
class MalformedAuthenticationException extends DecryptException { constructor() { super('authentication data') } }
class KeyExpiredDecryptException extends DecryptException { constructor() { super('key expired') } }
class KeyNotFoundDecryptException extends DecryptException { constructor(message: string) { super('key not found: ' + message) } }
+1
View File
@@ -18,3 +18,4 @@
export { decrypt } from './decrypt'
export { generateDhKeypair } from './genkey'
export { decryptParentPassword } from './parentpassword'
export { getSharedSecret, SharedSecretException } from './shared-secret'
+100
View File
@@ -0,0 +1,100 @@
/*
* server component for the TimeLimit App
* Copyright (C) 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import * as Sequelize from 'sequelize'
import { createPrivateKey, createPublicKey, diffieHellman } from 'crypto'
import { Database } from '../../database'
import { calculateExpireTime } from '../../database/devicedhkey'
import { isVersionId } from '../../util/token'
export async function getSharedSecret({
database, transaction, familyId, deviceId, keyId, otherPublicKey
}: {
database: Database
transaction: Sequelize.Transaction
familyId: string
deviceId: string
keyId: string
otherPublicKey: Buffer
}) {
if (!isVersionId(keyId)) throw new KeyNotFoundException('invalid key id')
const databaseKeyEntry = await database.deviceDhKey.findOne({
where: {
familyId,
deviceId,
version: keyId
},
transaction
})
if (!databaseKeyEntry) throw new KeyNotFoundException('private key not found')
if (databaseKeyEntry.expireAt === null) {
databaseKeyEntry.expireAt = calculateExpireTime(BigInt(Date.now())).toString(10)
await databaseKeyEntry.save({ transaction })
} else {
if (BigInt(databaseKeyEntry.expireAt) < BigInt(Date.now())) throw new KeyExpiredException()
}
const privateKey = (() => {
try {
return createPrivateKey({
key: databaseKeyEntry.privateKey,
format: 'der',
type: 'pkcs8'
})
} catch (ex) {
throw new MalformedPrivateKeyException()
}
})()
const decodedOtherPublicKey = (() => {
try {
return createPublicKey({
key: otherPublicKey,
format: 'der',
type: 'spki'
})
} catch (ex) {
throw new MalformedPublicKeyException()
}
})()
const sharedSecret = (() => {
try {
return diffieHellman({
privateKey,
publicKey: decodedOtherPublicKey
})
} catch (ex) {
throw new MalformedNoMatchingKeysException()
}
})()
return {
sharedSecret,
ownPublicKey: databaseKeyEntry.publicKey
}
}
export class SharedSecretException extends Error {}
class MalformedPrivateKeyException extends SharedSecretException { constructor() { super('private key') } }
class MalformedPublicKeyException extends SharedSecretException { constructor() { super('public key') } }
class MalformedNoMatchingKeysException extends SharedSecretException { constructor() { super('no matching keys') } }
class KeyExpiredException extends SharedSecretException { constructor() { super('key expired') } }
class KeyNotFoundException extends SharedSecretException { constructor(message: string) { super('key not found: ' + message) } }
+2 -1
View File
@@ -68,7 +68,8 @@ export const createFamily = async ({ database, mailAuthToken, firstParentDevice,
// 14 days demo version
fullVersionUntil: (Date.now() + 1000 * 60 * 60 * 24 * 14).toString(10),
hasFullVersion: true,
nextServerKeyRequestSeq: '1'
nextServerKeyRequestSeq: '1',
u2fKeysVersion: generateIdWithinFamily()
}, { transaction })
// create parent user
+14
View File
@@ -45,6 +45,7 @@ export class Cache {
invalidiateUserList = false
invalidiateDeviceList = false
invalidateU2fList = false
areChangesImportant = false
constructor ({ familyId, deviceId, hasFullVersion, database, transaction, connectedDevicesManager }: {
@@ -271,6 +272,19 @@ export class Cache {
this.invalidiateDeviceList = false
}
if (this.invalidateU2fList) {
await database.family.update({
u2fKeysVersion: generateVersionId()
}, {
where: {
familyId: this.familyId
},
transaction
})
this.invalidateU2fList = false
}
this.devicesWithModifiedShowDeviceConnected.forEach((showDeviceConnected, deviceId) => {
this.connectedDevicesManager.notifyShareConnectedChanged({
familyId: this.familyId,
@@ -1,6 +1,6 @@
/*
* server component for the TimeLimit App
* Copyright (C) 2019 - 2020 Jonas Lochmann
* Copyright (C) 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
@@ -24,14 +24,18 @@ import { Cache } from '../cache'
import { dispatchParentAction as dispatchParentActionInternal } from '../dispatch-parent-action'
import { SourceDeviceNotFoundException } from '../exception/illegal-state'
import { SelfLimitNotPossibleException } from '../exception/self-limit'
import { AuthenticationMethod } from '../types'
import { dispatch } from './helper'
export async function dispatchParentAction ({ action, eventHandler, cache, isChildLimitAdding, deviceId }: {
export async function dispatchParentAction ({
action, eventHandler, cache, isChildLimitAdding, deviceId, authentication
}: {
action: ClientPushChangesRequestAction
cache: Cache
eventHandler: EventHandler
isChildLimitAdding: boolean
deviceId: string
authentication: AuthenticationMethod
}) {
return dispatch({
action,
@@ -90,7 +94,8 @@ export async function dispatchParentAction ({ action, eventHandler, cache, isChi
cache,
parentUserId: action.userId,
sourceDeviceId: deviceId,
fromChildSelfLimitAddChildUserId: deviceUserId
fromChildSelfLimitAddChildUserId: deviceUserId,
authentication
})
} else {
await dispatchParentActionInternal({
@@ -98,7 +103,8 @@ export async function dispatchParentAction ({ action, eventHandler, cache, isChi
cache,
parentUserId: action.userId,
sourceDeviceId: deviceId,
fromChildSelfLimitAddChildUserId: null
fromChildSelfLimitAddChildUserId: null,
authentication
})
}
}
@@ -0,0 +1,56 @@
/*
* server component for the TimeLimit App
* Copyright (C) 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { AddParentU2fKeyAction } from '../../../../action'
import { getU2fKeyId } from '../../../../database/u2fkey'
import { Cache } from '../cache'
import { ApplyActionUnacceptableAuthMethodException } from '../exception/auth'
import { LimitReachedException } from '../exception/limit'
import { AuthenticationMethod } from '../types'
export async function dispatchAddU2f ({ action, cache, parentUserId, authentication }: {
action: AddParentU2fKeyAction
cache: Cache
parentUserId: string
authentication: AuthenticationMethod
}) {
if (authentication === 'u2f') {
throw new ApplyActionUnacceptableAuthMethodException()
}
const counter = await cache.database.u2fKey.count({
where: {
familyId: cache.familyId
},
transaction: cache.transaction
})
if (counter >= 16) throw new LimitReachedException({ type: 'u2f keys' })
await cache.database.u2fKey.create({
familyId: cache.familyId,
keyId: getU2fKeyId({ keyHandle: action.keyHandle, publicKey: action.publicKey }),
userId: parentUserId,
addedAt: Date.now().toString(10),
keyHandle: action.keyHandle,
publicKey: action.publicKey,
nextCounter: '0'
}, { transaction: cache.transaction })
cache.invalidateU2fList = true
cache.areChangesImportant = true
}
@@ -1,6 +1,6 @@
/*
* server component for the TimeLimit App
* Copyright (C) 2019 - 2020 Jonas Lochmann
* Copyright (C) 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
@@ -18,6 +18,7 @@
import {
AddCategoryAppsAction,
AddCategoryNetworkIdAction,
AddParentU2fKeyAction,
AddUserAction,
ChangeParentPasswordAction,
CreateCategoryAction,
@@ -29,7 +30,9 @@ import {
IncrementCategoryExtraTimeAction,
ParentAction,
RemoveCategoryAppsAction,
RemoveParentU2fKeyAction,
RemoveUserAction,
ReportU2fLoginAction,
RenameChildAction,
ResetCategoryNetworkIdsAction,
ReviewChildTaskAction,
@@ -68,8 +71,10 @@ import {
import { Cache } from '../cache'
import { ActionObjectTypeNotHandledException } from '../exception/illegal-state'
import { ActionNotSupportedBySelfLimitationException } from '../exception/self-limit'
import { AuthenticationMethod } from '../types'
import { dispatchAddCategoryApps } from './addcategoryapps'
import { dispatchAddCategoryNetworkId } from './addcategorynetworkid'
import { dispatchAddU2f } from './addu2fkey'
import { dispatchAddUser } from './adduser'
import { dispatchChangeParentPassword } from './changeparentpassword'
import { dispatchCreateCategory } from './createcategory'
@@ -80,7 +85,9 @@ import { dispatchDeleteTimeLimitRule } from './deletetimelimitrule'
import { dispatchIgnoreManipulation } from './ignoremanipulation'
import { dispatchIncrementCategoryExtraTime } from './incrementcategoryextratime'
import { dispatchRemoveCategoryApps } from './removecategoryapps'
import { dispatchRemoveU2f } from './removeu2fkey'
import { dispatchRemoveUser } from './removeuser'
import { dispatchReportU2fLogin } from './reportu2flogin'
import { dispatchRenameChild } from './renamechild'
import { dispatchResetCategoryNetworkIds } from './resetcategorynetworkids'
import { dispatchReviewChildTaskAction } from './reviewchildtaskaction'
@@ -116,12 +123,16 @@ import { dispatchUpdateUserFlagsAction } from './updateuserflags'
import { dispatchUpdateUserLimitLoginCategoryAction } from './updateuserlimitlogincategory'
import { dispatchUpdateUserLimitPreBlockDuration } from './updateuserlimitloginpreblockduration'
export const dispatchParentAction = async ({ action, cache, parentUserId, sourceDeviceId, fromChildSelfLimitAddChildUserId }: {
export const dispatchParentAction = async ({
action, cache, parentUserId, sourceDeviceId,
fromChildSelfLimitAddChildUserId, authentication
}: {
action: ParentAction
cache: Cache
parentUserId: string
sourceDeviceId: string | null
fromChildSelfLimitAddChildUserId: string | null
authentication: AuthenticationMethod
}) => {
if (action instanceof AddCategoryAppsAction) {
return dispatchAddCategoryApps({ action, cache, fromChildSelfLimitAddChildUserId })
@@ -146,10 +157,14 @@ export const dispatchParentAction = async ({ action, cache, parentUserId, source
} else {
if (action instanceof AddCategoryNetworkIdAction) {
return dispatchAddCategoryNetworkId({ action, cache })
} else if (action instanceof AddParentU2fKeyAction) {
return dispatchAddU2f({ action, cache, parentUserId, authentication })
} else if (action instanceof AddUserAction) {
return dispatchAddUser({ action, cache })
} else if (action instanceof RemoveCategoryAppsAction) {
return dispatchRemoveCategoryApps({ action, cache })
} else if (action instanceof RemoveParentU2fKeyAction) {
return dispatchRemoveU2f({ action, cache, parentUserId, authentication })
} else if (action instanceof DeleteCategoryAction) {
return dispatchDeleteCategory({ action, cache })
} else if (action instanceof UpdateCategoryTitleAction) {
@@ -200,6 +215,8 @@ export const dispatchParentAction = async ({ action, cache, parentUserId, source
return dispatchUpdateTimelimitRule({ action, cache })
} else if (action instanceof RemoveUserAction) {
return dispatchRemoveUser({ action, cache, parentUserId })
} else if (action instanceof ReportU2fLoginAction) {
return dispatchReportU2fLogin({ action, cache, authentication })
} else if (action instanceof ResetCategoryNetworkIdsAction) {
return dispatchResetCategoryNetworkIds({ action, cache })
} else if (action instanceof RenameChildAction) {
@@ -0,0 +1,47 @@
/*
* server component for the TimeLimit App
* Copyright (C) 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { RemoveParentU2fKeyAction } from '../../../../action'
import { getU2fKeyId } from '../../../../database/u2fkey'
import { Cache } from '../cache'
import { ApplyActionUnacceptableAuthMethodException } from '../exception/auth'
import { AuthenticationMethod } from '../types'
export async function dispatchRemoveU2f ({ action, cache, parentUserId, authentication }: {
action: RemoveParentU2fKeyAction
cache: Cache
parentUserId: string
authentication: AuthenticationMethod
}) {
if (authentication === 'u2f') {
throw new ApplyActionUnacceptableAuthMethodException()
}
await cache.database.u2fKey.destroy({
where: {
familyId: cache.familyId,
keyId: getU2fKeyId({ keyHandle: action.keyHandle, publicKey: action.publicKey }),
userId: parentUserId,
keyHandle: action.keyHandle,
publicKey: action.publicKey
},
transaction: cache.transaction
})
cache.invalidateU2fList = true
cache.areChangesImportant = true
}
@@ -0,0 +1,34 @@
/*
* server component for the TimeLimit App
* Copyright (C) 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { ReportU2fLoginAction } from '../../../../action'
import { Cache } from '../cache'
import { ApplyActionUnacceptableAuthMethodException } from '../exception/auth'
import { AuthenticationMethod } from '../types'
export async function dispatchReportU2fLogin ({ authentication }: {
action: ReportU2fLoginAction
cache: Cache
authentication: AuthenticationMethod
}) {
if (authentication !== 'u2f') {
throw new ApplyActionUnacceptableAuthMethodException()
}
// nothing to do; the goal was already reached by the authentication
// validation that expired the dh key
}
@@ -0,0 +1,22 @@
/*
* server component for the TimeLimit App
* Copyright (C) 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { ApplyActionException } from './index'
export class ApplyActionUnacceptableAuthMethodException extends ApplyActionException {
constructor() { super({ staticMessage: 'invalid auth method for the action' }) }
}
@@ -1,6 +1,6 @@
/*
* server component for the TimeLimit App
* Copyright (C) 2019 - 2020 Jonas Lochmann
* Copyright (C) 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
@@ -27,6 +27,10 @@ export class InvalidParentActionIntegrityValue extends ApplyActionIntegrityExcep
constructor () { super({ staticMessage: 'invalid parent action integrity value' }) }
}
export class InvalidU2fIntegrityValue extends ApplyActionIntegrityException {
constructor (message: string) { super({ staticMessage: 'invalid parent action u2f integrity value: ' + message }) }
}
export class InvalidChildActionIntegrityValue extends ApplyActionIntegrityException {
constructor () { super({ staticMessage: 'invalid child action integrity value' }) }
}
@@ -0,0 +1,24 @@
/*
* server component for the TimeLimit App
* Copyright (C) 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { ApplyActionException } from './index'
export class LimitReachedException extends ApplyActionException {
constructor({type}: { type: string }) {
super({ staticMessage: 'limit reached: ' + type })
}
}
+14 -7
View File
@@ -71,12 +71,6 @@ export const applyActionsFromDevice = async ({ database, request, websocket, con
// update the next sequence number
nextSequenceNumber = action.sequenceNumber + 1
const { isChildLimitAdding } = await assertActionIntegrity({
deviceId: baseInfo.deviceId,
cache,
action
})
if (action.type === 'appLogic') {
await dispatchAppLogicAction({
action,
@@ -85,14 +79,27 @@ export const applyActionsFromDevice = async ({ database, request, websocket, con
eventHandler
})
} else if (action.type === 'parent') {
const { isChildLimitAdding, authentication } = await assertActionIntegrity({
deviceId: baseInfo.deviceId,
cache,
action
})
await dispatchParentAction({
action,
cache,
deviceId: baseInfo.deviceId,
eventHandler,
isChildLimitAdding
isChildLimitAdding,
authentication
})
} else if (action.type === 'child') {
await assertActionIntegrity({
deviceId: baseInfo.deviceId,
cache,
action
})
await dispatchChildAction({
action,
cache,
+103 -13
View File
@@ -1,6 +1,6 @@
/*
* server component for the TimeLimit App
* Copyright (C) 2019 - 2020 Jonas Lochmann
* Copyright (C) 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
@@ -15,20 +15,26 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { createHash } from 'crypto'
import { createHash, createHmac, timingSafeEqual } from 'crypto'
import { ClientPushChangesRequestAction } from '../../../api/schema'
import { intToBuffer, longToBuffer } from '../../../util/binary-number'
import { validateU2fIntegrity, U2fValidationError } from '../../u2f'
import { Cache } from './cache'
import {
InvalidChildActionIntegrityValue, InvalidParentActionIntegrityValue, ParentDeviceActionWithoutParentDeviceException
InvalidChildActionIntegrityValue, InvalidParentActionIntegrityValue,
ParentDeviceActionWithoutParentDeviceException, InvalidU2fIntegrityValue
} from './exception/integrity'
import { ActionObjectTypeNotHandledException } from './exception/illegal-state'
import { AuthenticationMethod } from './types'
export async function assertActionIntegrity ({ action, cache, deviceId }: {
action: ClientPushChangesRequestAction
cache: Cache
deviceId: string
}): Promise<{ isChildLimitAdding: boolean }> {
let isChildLimitAdding = false
}): Promise<{
isChildLimitAdding: boolean
authentication: AuthenticationMethod
}> {
if (action.type === 'parent') {
if (action.integrity === 'device') {
const deviceEntryUnsafe = await cache.database.device.findOne({
@@ -48,10 +54,69 @@ export async function assertActionIntegrity ({ action, cache, deviceId }: {
// this ensures that the parent exists
await cache.getSecondPasswordHashOfParent(action.userId)
return {
isChildLimitAdding: false,
authentication: 'device'
}
} else if (action.integrity === 'childDevice') {
// will be checked later
isChildLimitAdding = true
return {
isChildLimitAdding: true, // will be checked later
authentication: 'device'
}
} else if (action.integrity.startsWith('u2f:')) {
// this ensures that the parent exists
await cache.getSecondPasswordHashOfParent(action.userId)
try {
const checkResult = await validateU2fIntegrity({
integrity: action.integrity,
hasFullVersion: cache.hasFullVersion,
familyId: cache.familyId,
deviceId,
database: cache.database,
transaction: cache.transaction,
calculateHmac: (secret) => calculateActionHmac({
action,
deviceId,
secret
})
})
if (checkResult.userId !== action.userId) {
throw new InvalidParentActionIntegrityValue()
}
} catch (ex) {
if (ex instanceof U2fValidationError) throw new InvalidU2fIntegrityValue(ex.message)
else throw ex
}
return {
isChildLimitAdding: false,
authentication: 'u2f'
}
} else if (action.integrity.startsWith('password:')) {
// password method with hmac
const parentSecondHash = await cache.getSecondPasswordHashOfParent(action.userId)
const correctResponse = calculateActionHmac({
action,
deviceId,
secret: Buffer.from(parentSecondHash, 'utf8')
})
const providedResult = Buffer.from(action.integrity.substring(9), 'base64')
if (!timingSafeEqual(providedResult, correctResponse)) {
throw new InvalidParentActionIntegrityValue()
}
return {
isChildLimitAdding: false,
authentication: 'password'
}
} else {
// legacy password method
const parentSecondHash = await cache.getSecondPasswordHashOfParent(action.userId)
const integrityData = action.sequenceNumber.toString(10) +
@@ -64,10 +129,13 @@ export async function assertActionIntegrity ({ action, cache, deviceId }: {
if (action.integrity !== expectedIntegrityValue) {
throw new InvalidParentActionIntegrityValue()
}
}
}
if (action.type === 'child') {
return {
isChildLimitAdding: false,
authentication: 'password'
}
}
} else if (action.type === 'child') {
const childSecondHash = await cache.getSecondPasswordHashOfChild(action.userId)
const integrityData = action.sequenceNumber.toString(10) +
@@ -80,7 +148,29 @@ export async function assertActionIntegrity ({ action, cache, deviceId }: {
if (action.integrity !== expectedIntegrityValue) {
throw new InvalidChildActionIntegrityValue()
}
}
return { isChildLimitAdding }
return {
isChildLimitAdding: false,
authentication: 'password'
}
} else {
throw new ActionObjectTypeNotHandledException()
}
}
function calculateActionHmac({ action, deviceId, secret }: {
action: ClientPushChangesRequestAction
deviceId: string
secret: Buffer
}): Buffer {
const binaryDeviceId = Buffer.from(deviceId, 'utf8')
const binaryAction = Buffer.from(action.encodedAction, 'utf8')
return createHmac('sha256', secret)
.update(longToBuffer(BigInt(action.sequenceNumber)))
.update(intToBuffer(binaryDeviceId.length))
.update(binaryDeviceId)
.update(intToBuffer(binaryAction.length))
.update(binaryAction)
.digest()
}
+18
View File
@@ -0,0 +1,18 @@
/*
* server component for the TimeLimit App
* Copyright (C) 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export type AuthenticationMethod = 'device' | 'password' | 'u2f'
@@ -1,6 +1,6 @@
/*
* server component for the TimeLimit App
* Copyright (C) 2019 - 2020 Jonas Lochmann
* Copyright (C) 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
@@ -25,6 +25,7 @@ export interface FamilyEntry {
userListVersion: string
hasFullVersion: boolean
fullVersionUntil: string
u2fKeysVersion: string
}
export async function getFamilyEntry ({ database, familyId, transaction }: {
@@ -40,7 +41,8 @@ export async function getFamilyEntry ({ database, familyId, transaction }: {
'deviceListVersion',
'userListVersion',
'hasFullVersion',
'fullVersionUntil'
'fullVersionUntil',
'u2fKeysVersion'
],
transaction
})
@@ -54,6 +56,7 @@ export async function getFamilyEntry ({ database, familyId, transaction }: {
deviceListVersion: familyEntryUnsafe.deviceListVersion,
userListVersion: familyEntryUnsafe.userListVersion,
hasFullVersion: familyEntryUnsafe.hasFullVersion,
fullVersionUntil: familyEntryUnsafe.fullVersionUntil
fullVersionUntil: familyEntryUnsafe.fullVersionUntil,
u2fKeysVersion: familyEntryUnsafe.u2fKeysVersion
}
}
@@ -34,6 +34,7 @@ import { getFamilyEntry } from './family-entry'
import { getUserList } from './user-list'
import { getKeyRequests } from './key-requests'
import { getKeyResponses } from './key-responses'
import { getU2f } from './u2f'
export const generateServerDataStatus = async ({
database, clientStatus, familyId, deviceId, transaction, eventHandler
@@ -51,13 +52,14 @@ export const generateServerDataStatus = async ({
const doesClientSupportTasks = clientLevel >= 3
const doesClientSupportCryptoApps = clientLevel >= 4
const doesClientSupportDh = clientLevel >= 5
const doesClientSupportU2f = clientLevel >= 6
const result: ServerDataStatus = {
fullVersion: config.alwaysPro ? 1 : (
familyEntry.hasFullVersion ? parseInt(familyEntry.fullVersionUntil, 10) : 0
),
message: await getStatusMessage({ database, transaction }) || undefined,
apiLevel: 5
apiLevel: 6
}
if (familyEntry.deviceListVersion !== clientStatus.devices) {
@@ -152,5 +154,14 @@ export const generateServerDataStatus = async ({
}) || undefined
}
if (doesClientSupportU2f) {
result.u2f = await getU2f({
database,
transaction,
familyEntry,
lastVersionId: clientStatus.u2f || null
}) || undefined
}
return result
}
@@ -0,0 +1,49 @@
/*
* server component for the TimeLimit App
* Copyright (C) 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import * as Sequelize from 'sequelize'
import { Database } from '../../../database'
import { U2fData } from '../../../object/serverdatastatus'
import { FamilyEntry } from './family-entry'
export async function getU2f ({
database, transaction, familyEntry, lastVersionId
}: {
database: Database
transaction: Sequelize.Transaction
familyEntry: FamilyEntry
lastVersionId: string | null
}): Promise<U2fData | null> {
if (lastVersionId === familyEntry.u2fKeysVersion) return null
const savedData = await database.u2fKey.findAll({
where: {
familyId: familyEntry.familyId
},
transaction
})
return {
v: familyEntry.u2fKeysVersion,
d: savedData.map((item) => ({
u: item.userId,
a: parseInt(item.addedAt, 10),
h: item.keyHandle.toString('base64'),
p: item.publicKey.toString('base64')
}))
}
}
+148
View File
@@ -0,0 +1,148 @@
/*
* server component for the TimeLimit App
* Copyright (C) 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { createHash, timingSafeEqual } from 'crypto'
import * as Sequelize from 'sequelize'
import { getSharedSecret, SharedSecretException } from '../dh'
import { Database } from '../../database'
import { intToBuffer } from '../../util/binary-number'
import { isU2fSignatureValid, calculateApplicationId } from '../../util/u2fsignature'
export class U2fValidationError extends Error {}
class IntegrityMalformedException extends U2fValidationError { constructor() { super('integrity malformed') } }
class MissingPremiumException extends U2fValidationError { constructor() { super('missing premium') } }
class U2fSharedSecretException extends U2fValidationError { constructor(message: string) { super('shared secret: ' + message) } }
class HmacMismatchException extends U2fValidationError { constructor() { super('hmac mismatch') } }
class UnknownU2fKeyIdException extends U2fValidationError { constructor() { super('unknown u2f key id') } }
class InvalidU2fSignatureException extends U2fValidationError { constructor() { super('u2f signature invalid') } }
export async function validateU2fIntegrity({
integrity,
hasFullVersion,
familyId,
deviceId,
database,
transaction,
calculateHmac
}: {
integrity: string
hasFullVersion: boolean
familyId: string
deviceId: string
database: Database
transaction: Sequelize.Transaction
calculateHmac: (secret: Buffer) => Buffer
}) {
if (!integrity.startsWith('u2f:')) throw new IntegrityMalformedException()
const parts = integrity.substring(4).split('.')
if (parts.length !== 5) {
throw new IntegrityMalformedException()
}
if (!hasFullVersion) {
throw new MissingPremiumException()
}
const [dhKeyId, dhPublicKeyBase64, u2fKeyId, u2fResponseBase64, providedHmacResultBase64] = parts
const binaryDhKeyId = Buffer.from(dhKeyId, 'utf8')
const dhPublicKey = Buffer.from(dhPublicKeyBase64, 'base64')
const u2fResponse = Buffer.from(u2fResponseBase64, 'base64')
const providedHmacResult = Buffer.from(providedHmacResultBase64, 'base64')
const sharedSecret = await (async () => {
try {
return await getSharedSecret({
database,
transaction,
familyId,
deviceId,
keyId: dhKeyId,
otherPublicKey: dhPublicKey
})
} catch (ex) {
if (ex instanceof SharedSecretException) throw new U2fSharedSecretException(ex.message)
else throw ex
}
})()
const correctHmac = calculateHmac(sharedSecret.sharedSecret)
if (!timingSafeEqual(providedHmacResult, correctHmac)) {
throw new HmacMismatchException()
}
const keyDescriptorUnsafe = await database.u2fKey.findOne({
where: {
familyId,
keyId: u2fKeyId
},
transaction,
attributes: ['publicKey', 'userId']
})
if (keyDescriptorUnsafe === null) throw new UnknownU2fKeyIdException()
const keyDescriptor = {
publicKey: keyDescriptorUnsafe.publicKey,
userId: keyDescriptorUnsafe.userId
}
const dhPublicKeysHash = createHash('sha256')
.update(intToBuffer(binaryDhKeyId.length))
.update(binaryDhKeyId)
.update(intToBuffer(sharedSecret.ownPublicKey.length))
.update(sharedSecret.ownPublicKey)
.update(intToBuffer(dhPublicKey.length))
.update(dhPublicKey)
.digest()
if (
!isU2fSignatureValid({
u2fRawResponse: u2fResponse,
applicationId: calculateApplicationId('https://timelimit.io'),
challenge: dhPublicKeysHash,
publicKey: keyDescriptor.publicKey
})
) {
throw new InvalidU2fSignatureException()
}
const u2fCounter = u2fResponse.readUInt32BE(1)
// the counter is not checked at the server
// this happens because the offline usage can cause receiving actions
// out of order so it would be required to keep track of the used counter
// values; if this becomes necassary in the future, then it does not
// require any client modification to add it
await database.u2fKey.update({
nextCounter: (u2fCounter + 1).toString(10)
}, {
where: {
familyId,
keyId: u2fKeyId
},
transaction
})
return {
userId: keyDescriptor.userId
}
}