feat: add @standardnotes/encryption package (#1199)

* feat: add @standardnotes/encryption package

* fix: mobile dependency on encryption package

* fix: order of build & lint in pr workflows

* fix: web dependency on encryption package

* fix: remove encryption package composite configuration

* fix: import order
This commit is contained in:
Karol Sójko
2022-07-05 10:06:03 +02:00
committed by GitHub
parent 60273785c2
commit e5771fcbde
70 changed files with 4682 additions and 27 deletions

View File

@@ -0,0 +1,46 @@
import {
ConflictStrategy,
ItemsKeyContent,
DecryptedItem,
DecryptedPayloadInterface,
DecryptedItemInterface,
HistoryEntryInterface,
ItemsKeyInterface,
RootKeyInterface,
} from '@standardnotes/models'
import { ContentType, ProtocolVersion } from '@standardnotes/common'
export function isItemsKey(x: ItemsKeyInterface | RootKeyInterface): x is ItemsKeyInterface {
return x.content_type === ContentType.ItemsKey
}
/**
* A key used to encrypt other items. Items keys are synced and persisted.
*/
export class SNItemsKey extends DecryptedItem<ItemsKeyContent> implements ItemsKeyInterface {
keyVersion: ProtocolVersion
isDefault: boolean | undefined
itemsKey: string
constructor(payload: DecryptedPayloadInterface<ItemsKeyContent>) {
super(payload)
this.keyVersion = payload.content.version
this.isDefault = payload.content.isDefault
this.itemsKey = this.payload.content.itemsKey
}
/** Do not duplicate items keys. Always keep original */
override strategyWhenConflictingWithItem(
_item: DecryptedItemInterface,
_previousRevision?: HistoryEntryInterface,
): ConflictStrategy {
return ConflictStrategy.KeepBase
}
get dataAuthenticationKey(): string | undefined {
if (this.keyVersion === ProtocolVersion.V004) {
throw 'Attempting to access legacy data authentication key.'
}
return this.payload.content.dataAuthenticationKey
}
}

View File

@@ -0,0 +1,7 @@
import { DecryptedItemMutator, ItemsKeyMutatorInterface, ItemsKeyContent } from '@standardnotes/models'
export class ItemsKeyMutator extends DecryptedItemMutator<ItemsKeyContent> implements ItemsKeyMutatorInterface {
set isDefault(isDefault: boolean) {
this.mutableContent.isDefault = isDefault
}
}

View File

@@ -0,0 +1,6 @@
import { ContentType } from '@standardnotes/common'
import { RegisterItemClass, DecryptedItemMutator, ItemsKeyContent } from '@standardnotes/models'
import { SNItemsKey } from './ItemsKey'
import { ItemsKeyMutator } from './ItemsKeyMutator'
RegisterItemClass(ContentType.ItemsKey, SNItemsKey, ItemsKeyMutator as unknown as DecryptedItemMutator<ItemsKeyContent>)

View File

@@ -0,0 +1,3 @@
export * from './ItemsKey'
export * from './ItemsKeyMutator'
export * from './Registration'

View File

@@ -0,0 +1,51 @@
import { SNRootKey } from './RootKey'
import {
DecryptedPayload,
FillItemContentSpecialized,
PayloadTimestampDefaults,
RootKeyContent,
RootKeyContentSpecialized,
} from '@standardnotes/models'
import { UuidGenerator } from '@standardnotes/utils'
import { ContentType, ProtocolVersion } from '@standardnotes/common'
export function CreateNewRootKey(content: RootKeyContentSpecialized): SNRootKey {
const uuid = UuidGenerator.GenerateUuid()
const payload = new DecryptedPayload<RootKeyContent>({
uuid: uuid,
content_type: ContentType.RootKey,
content: FillRootKeyContent(content),
...PayloadTimestampDefaults(),
})
return new SNRootKey(payload)
}
export function FillRootKeyContent(content: Partial<RootKeyContentSpecialized>): RootKeyContent {
if (!content.version) {
if (content.dataAuthenticationKey) {
/**
* If there's no version stored, it must be either 001 or 002.
* If there's a dataAuthenticationKey, it has to be 002. Otherwise it's 001.
*/
content.version = ProtocolVersion.V002
} else {
content.version = ProtocolVersion.V001
}
}
return FillItemContentSpecialized(content)
}
export function ContentTypeUsesRootKeyEncryption(contentType: ContentType): boolean {
return (
contentType === ContentType.RootKey ||
contentType === ContentType.ItemsKey ||
contentType === ContentType.EncryptedStorage
)
}
export function ItemContentTypeUsesRootKeyEncryption(contentType: ContentType): boolean {
return contentType === ContentType.ItemsKey
}

View File

@@ -0,0 +1,60 @@
import { KeyParamsResponse } from '@standardnotes/responses'
import {
KeyParamsContent001,
KeyParamsContent002,
KeyParamsContent003,
KeyParamsContent004,
AnyKeyParamsContent,
} from '@standardnotes/common'
import { SNRootKeyParams } from './RootKeyParams'
import { ProtocolVersionForKeyParams } from './ProtocolVersionForKeyParams'
/**
* 001, 002:
* - Nonce is not uploaded to server, instead used to compute salt locally and send to server
* - Salt is returned from server
* - Cost/iteration count is returned from the server
* - Account identifier is returned as 'email'
* 003, 004:
* - Salt is computed locally via the seed (pw_nonce) returned from the server
* - Cost/iteration count is determined locally by the protocol version
* - Account identifier is returned as 'identifier'
*/
export type AllKeyParamsContents = KeyParamsContent001 & KeyParamsContent002 & KeyParamsContent003 & KeyParamsContent004
export function Create001KeyParams(keyParams: KeyParamsContent001) {
return CreateAnyKeyParams(keyParams)
}
export function Create002KeyParams(keyParams: KeyParamsContent002) {
return CreateAnyKeyParams(keyParams)
}
export function Create003KeyParams(keyParams: KeyParamsContent003) {
return CreateAnyKeyParams(keyParams)
}
export function Create004KeyParams(keyParams: KeyParamsContent004) {
return CreateAnyKeyParams(keyParams)
}
export function CreateAnyKeyParams(keyParams: AnyKeyParamsContent) {
if ('content' in keyParams) {
throw Error('Raw key params shouldnt have content; perhaps you passed in a SNRootKeyParams object.')
}
return new SNRootKeyParams(keyParams)
}
export function KeyParamsFromApiResponse(response: KeyParamsResponse, identifier?: string) {
const rawKeyParams: AnyKeyParamsContent = {
identifier: identifier || response.data.identifier!,
pw_cost: response.data.pw_cost!,
pw_nonce: response.data.pw_nonce!,
pw_salt: response.data.pw_salt!,
version: ProtocolVersionForKeyParams(response.data),
origination: response.data.origination,
created: response.data.created,
}
return CreateAnyKeyParams(rawKeyParams)
}

View File

@@ -0,0 +1,49 @@
import { V001Algorithm, V002Algorithm } from '../../Algorithm'
import { KeyParamsData } from '@standardnotes/responses'
import { AnyKeyParamsContent, ProtocolVersion } from '@standardnotes/common'
export function ProtocolVersionForKeyParams(response: KeyParamsData | AnyKeyParamsContent): ProtocolVersion {
if (response.version) {
return response.version
}
/**
* 001 and 002 key params (as stored locally) may not report a version number.
* In some cases it may be impossible to differentiate between 001 and 002 params,
* but there are a few rules we can use to find a best fit.
*/
/**
* First try to determine by cost. If the cost appears in V002 costs but not V001 costs,
* we know it's 002.
*/
const cost = response.pw_cost!
const appearsInV001 = V001Algorithm.PbkdfCostsUsed.includes(cost)
const appearsInV002 = V002Algorithm.PbkdfCostsUsed.includes(cost)
if (appearsInV001 && !appearsInV002) {
return ProtocolVersion.V001
} else if (appearsInV002 && !appearsInV001) {
return ProtocolVersion.V002
} else if (appearsInV002 && appearsInV001) {
/**
* If the cost appears in both versions, we can be certain it's 002 if it's missing
* the pw_nonce property. (However late versions of 002 also used a pw_nonce, so its
* presence doesn't automatically indicate 001.)
*/
if (!response.pw_nonce) {
return ProtocolVersion.V002
} else {
/**
* We're now at the point that the cost has appeared in both versions, and a pw_nonce
* is present. We'll have to go with what is more statistically likely.
*/
if (V002Algorithm.ImprobablePbkdfCostsUsed.includes(cost)) {
return ProtocolVersion.V001
} else {
return ProtocolVersion.V002
}
}
} else {
/** Doesn't appear in either V001 or V002; unlikely possibility. */
return ProtocolVersion.V002
}
}

View File

@@ -0,0 +1,95 @@
import { SNRootKeyParams } from './RootKeyParams'
import {
RootKeyInterface,
RootKeyContent,
DecryptedItem,
DecryptedPayloadInterface,
RootKeyContentInStorage,
NamespacedRootKeyInKeychain,
} from '@standardnotes/models'
import { ProtocolVersion } from '@standardnotes/common'
import { timingSafeEqual } from '@standardnotes/sncrypto-common'
/**
* A root key is a local only construct that houses the key used for the encryption
* and decryption of items keys. A root key extends SNItem for local convenience, but is
* not part of the syncing or storage ecosystem—root keys are managed independently.
*/
export class SNRootKey extends DecryptedItem<RootKeyContent> implements RootKeyInterface {
public readonly keyParams: SNRootKeyParams
constructor(payload: DecryptedPayloadInterface<RootKeyContent>) {
super(payload)
this.keyParams = new SNRootKeyParams(payload.content.keyParams)
}
public get keyVersion(): ProtocolVersion {
return this.content.version
}
/**
* When the root key is used to encrypt items, we use the masterKey directly.
*/
public get itemsKey(): string {
return this.masterKey
}
public get masterKey(): string {
return this.content.masterKey
}
/**
* serverPassword is not persisted as part of keychainValue, so if loaded from disk,
* this value may be undefined.
*/
public get serverPassword(): string | undefined {
return this.content.serverPassword
}
/** 003 and below only. */
public get dataAuthenticationKey(): string | undefined {
return this.content.dataAuthenticationKey
}
public compare(otherKey: SNRootKey): boolean {
if (this.keyVersion !== otherKey.keyVersion) {
return false
}
if (this.serverPassword && otherKey.serverPassword) {
return (
timingSafeEqual(this.masterKey, otherKey.masterKey) &&
timingSafeEqual(this.serverPassword, otherKey.serverPassword)
)
} else {
return timingSafeEqual(this.masterKey, otherKey.masterKey)
}
}
/**
* @returns Object suitable for persist in storage when wrapped
*/
public persistableValueWhenWrapping(): RootKeyContentInStorage {
return {
...this.getKeychainValue(),
keyParams: this.keyParams.getPortableValue(),
}
}
/**
* @returns Object that is suitable for persisting in a keychain
*/
public getKeychainValue(): NamespacedRootKeyInKeychain {
const values: NamespacedRootKeyInKeychain = {
version: this.keyVersion,
masterKey: this.masterKey,
}
if (this.dataAuthenticationKey) {
values.dataAuthenticationKey = this.dataAuthenticationKey
}
return values
}
}

View File

@@ -0,0 +1,82 @@
import {
KeyParamsContent001,
KeyParamsContent002,
KeyParamsContent003,
KeyParamsContent004,
AnyKeyParamsContent,
ProtocolVersion,
KeyParamsOrigination,
} from '@standardnotes/common'
import { RootKeyParamsInterface } from '@standardnotes/models'
import { pickByCopy } from '@standardnotes/utils'
import { ProtocolVersionForKeyParams } from './ProtocolVersionForKeyParams'
import { ValidKeyParamsKeys } from './ValidKeyParamsKeys'
export class SNRootKeyParams implements RootKeyParamsInterface {
public readonly content: AnyKeyParamsContent
constructor(content: AnyKeyParamsContent) {
this.content = {
...content,
origination: content.origination || KeyParamsOrigination.Registration,
version: content.version || ProtocolVersionForKeyParams(content),
}
}
get isKeyParamsObject(): boolean {
return true
}
get identifier(): string {
return this.content004.identifier || this.content002.email
}
get version(): ProtocolVersion {
return this.content.version
}
get origination(): KeyParamsOrigination | undefined {
return this.content.origination
}
get content001(): KeyParamsContent001 {
return this.content as KeyParamsContent001
}
get content002(): KeyParamsContent002 {
return this.content as KeyParamsContent002
}
get content003(): KeyParamsContent003 {
return this.content as KeyParamsContent003
}
get content004(): KeyParamsContent004 {
return this.content as KeyParamsContent004
}
get createdDate(): Date | undefined {
if (!this.content004.created) {
return undefined
}
return new Date(Number(this.content004.created))
}
compare(other: SNRootKeyParams): boolean {
if (this.version !== other.version) {
return false
}
if ([ProtocolVersion.V004, ProtocolVersion.V003].includes(this.version)) {
return this.identifier === other.identifier && this.content004.pw_nonce === other.content003.pw_nonce
} else if ([ProtocolVersion.V002, ProtocolVersion.V001].includes(this.version)) {
return this.identifier === other.identifier && this.content002.pw_salt === other.content001.pw_salt
} else {
throw Error('Unhandled version in KeyParams.compare')
}
}
getPortableValue(): AnyKeyParamsContent {
return pickByCopy(this.content, ValidKeyParamsKeys as (keyof AnyKeyParamsContent)[])
}
}

View File

@@ -0,0 +1,11 @@
import { AllKeyParamsContents } from './KeyParamsFunctions'
export const ValidKeyParamsKeys: (keyof AllKeyParamsContents)[] = [
'identifier',
'pw_cost',
'pw_nonce',
'pw_salt',
'version',
'origination',
'created',
]

View File

@@ -0,0 +1,95 @@
import {
DecryptedPayloadInterface,
EncryptedPayloadInterface,
isDecryptedPayload,
ItemsKeyContent,
RootKeyInterface,
} from '@standardnotes/models'
import {
ChallengePrompt,
ChallengeReason,
ChallengeServiceInterface,
ChallengeValidation,
} from '@standardnotes/services'
import { EncryptionProvider } from '../../Service/Encryption/EncryptionProvider'
import { SNRootKeyParams } from '../RootKey/RootKeyParams'
import { KeyRecoveryStrings } from './KeyRecoveryStrings'
export async function DecryptItemsKeyWithUserFallback(
itemsKey: EncryptedPayloadInterface,
encryptor: EncryptionProvider,
challengor: ChallengeServiceInterface,
): Promise<DecryptedPayloadInterface<ItemsKeyContent> | 'failed' | 'aborted'> {
const decryptionResult = await encryptor.decryptSplitSingle<ItemsKeyContent>({
usesRootKeyWithKeyLookup: {
items: [itemsKey],
},
})
if (isDecryptedPayload(decryptionResult)) {
return decryptionResult
}
const secondDecryptionResult = await DecryptItemsKeyByPromptingUser(itemsKey, encryptor, challengor)
if (secondDecryptionResult === 'aborted' || secondDecryptionResult === 'failed') {
return secondDecryptionResult
}
return secondDecryptionResult.decryptedKey
}
export async function DecryptItemsKeyByPromptingUser(
itemsKey: EncryptedPayloadInterface,
encryptor: EncryptionProvider,
challengor: ChallengeServiceInterface,
keyParams?: SNRootKeyParams,
): Promise<
| {
decryptedKey: DecryptedPayloadInterface<ItemsKeyContent>
rootKey: RootKeyInterface
}
| 'failed'
| 'aborted'
> {
if (!keyParams) {
keyParams = encryptor.getKeyEmbeddedKeyParams(itemsKey)
}
if (!keyParams) {
return 'failed'
}
const challenge = challengor.createChallenge(
[new ChallengePrompt(ChallengeValidation.None, undefined, undefined, true)],
ChallengeReason.Custom,
true,
KeyRecoveryStrings.KeyRecoveryLoginFlowPrompt(keyParams),
KeyRecoveryStrings.KeyRecoveryPasswordRequired,
)
const response = await challengor.promptForChallengeResponse(challenge)
if (!response) {
return 'aborted'
}
const password = response.values[0].value as string
const rootKey = await encryptor.computeRootKey(password, keyParams)
const secondDecryptionResult = await encryptor.decryptSplitSingle<ItemsKeyContent>({
usesRootKey: {
items: [itemsKey],
key: rootKey,
},
})
challengor.completeChallenge(challenge)
if (isDecryptedPayload(secondDecryptionResult)) {
return { decryptedKey: secondDecryptionResult, rootKey }
}
return 'failed'
}

View File

@@ -0,0 +1,32 @@
import { KeyParamsOrigination } from '@standardnotes/common'
import { SNRootKeyParams } from '../RootKey/RootKeyParams'
export const KeyRecoveryStrings = {
KeyRecoveryLoginFlowPrompt: (keyParams: SNRootKeyParams) => {
const dateString = keyParams.createdDate?.toLocaleString()
switch (keyParams.origination) {
case KeyParamsOrigination.EmailChange:
return `Enter your account password as it was when you changed your email on ${dateString}.`
case KeyParamsOrigination.PasswordChange:
return `Enter your account password after it was changed on ${dateString}.`
case KeyParamsOrigination.Registration:
return `Enter your account password as it was when you registered ${dateString}.`
case KeyParamsOrigination.ProtocolUpgrade:
return `Enter your account password as it was when you upgraded your encryption version on ${dateString}.`
case KeyParamsOrigination.PasscodeChange:
return `Enter your application passcode after it was changed on ${dateString}.`
case KeyParamsOrigination.PasscodeCreate:
return `Enter your application passcode as it was when you created it on ${dateString}.`
default:
throw Error('Unhandled KeyParamsOrigination case for KeyRecoveryLoginFlowPrompt')
}
},
KeyRecoveryLoginFlowReason: 'Your account password is required to revalidate your session.',
KeyRecoveryLoginFlowInvalidPassword: 'Incorrect credentials entered. Please try again.',
KeyRecoveryRootKeyReplaced: 'Your credentials have successfully been updated.',
KeyRecoveryPasscodeRequiredTitle: 'Passcode Required',
KeyRecoveryPasscodeRequiredText: 'You must enter your passcode in order to save your new credentials.',
KeyRecoveryPasswordRequired: 'Your account password is required to recover an encryption key.',
KeyRecoveryKeyRecovered: 'Your key has successfully been recovered.',
KeyRecoveryUnableToRecover: 'Unable to recover your key with the attempted password. Please try again.',
}