feat: add sending user requests to process (#1908)
* feat: add sending user requests to process * fix(snjs): yarn lock * fix(snjs): imports * fix: specs
This commit is contained in:
@@ -16,7 +16,7 @@ import { StorageValueModes } from '../Storage/StorageTypes'
|
||||
|
||||
import { DeinitMode } from './DeinitMode'
|
||||
import { DeinitSource } from './DeinitSource'
|
||||
import { UserClientInterface } from './UserClientInterface'
|
||||
import { UserClientInterface } from '../User/UserClientInterface'
|
||||
|
||||
export interface ApplicationInterface {
|
||||
deinit(mode: DeinitMode, source: DeinitSource): void
|
||||
|
||||
117
packages/services/src/Domain/Challenge/Challenge.ts
Normal file
117
packages/services/src/Domain/Challenge/Challenge.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { assertUnreachable } from '@standardnotes/utils'
|
||||
import { ChallengeModalTitle, ChallengeStrings } from '../Strings/Messages'
|
||||
import { ChallengeInterface } from './ChallengeInterface'
|
||||
import { ChallengePrompt } from './Prompt/ChallengePrompt'
|
||||
import { ChallengeReason } from './Types/ChallengeReason'
|
||||
import { ChallengeValidation } from './Types/ChallengeValidation'
|
||||
|
||||
/**
|
||||
* A challenge is a stateless description of what the client needs to provide
|
||||
* in order to proceed.
|
||||
*/
|
||||
export class Challenge implements ChallengeInterface {
|
||||
public readonly id = Math.random()
|
||||
|
||||
constructor(
|
||||
public readonly prompts: ChallengePrompt[],
|
||||
public readonly reason: ChallengeReason,
|
||||
public readonly cancelable: boolean,
|
||||
public readonly _heading?: string,
|
||||
public readonly _subheading?: string,
|
||||
) {
|
||||
Object.freeze(this)
|
||||
}
|
||||
|
||||
/** Outside of the modal, this is the title of the modal itself */
|
||||
get modalTitle(): string {
|
||||
switch (this.reason) {
|
||||
case ChallengeReason.Migration:
|
||||
return ChallengeModalTitle.Migration
|
||||
default:
|
||||
return ChallengeModalTitle.Generic
|
||||
}
|
||||
}
|
||||
|
||||
/** Inside of the modal, this is the H1 */
|
||||
get heading(): string | undefined {
|
||||
if (this._heading) {
|
||||
return this._heading
|
||||
} else {
|
||||
switch (this.reason) {
|
||||
case ChallengeReason.ApplicationUnlock:
|
||||
return ChallengeStrings.UnlockApplication
|
||||
case ChallengeReason.Migration:
|
||||
return ChallengeStrings.EnterLocalPasscode
|
||||
case ChallengeReason.ResaveRootKey:
|
||||
return ChallengeStrings.EnterPasscodeForRootResave
|
||||
case ChallengeReason.ProtocolUpgrade:
|
||||
return ChallengeStrings.EnterCredentialsForProtocolUpgrade
|
||||
case ChallengeReason.AccessProtectedNote:
|
||||
return ChallengeStrings.NoteAccess
|
||||
case ChallengeReason.AccessProtectedFile:
|
||||
return ChallengeStrings.FileAccess
|
||||
case ChallengeReason.ImportFile:
|
||||
return ChallengeStrings.ImportFile
|
||||
case ChallengeReason.AddPasscode:
|
||||
return ChallengeStrings.AddPasscode
|
||||
case ChallengeReason.RemovePasscode:
|
||||
return ChallengeStrings.RemovePasscode
|
||||
case ChallengeReason.ChangePasscode:
|
||||
return ChallengeStrings.ChangePasscode
|
||||
case ChallengeReason.ChangeAutolockInterval:
|
||||
return ChallengeStrings.ChangeAutolockInterval
|
||||
case ChallengeReason.CreateDecryptedBackupWithProtectedItems:
|
||||
return ChallengeStrings.EnterCredentialsForDecryptedBackupDownload
|
||||
case ChallengeReason.RevokeSession:
|
||||
return ChallengeStrings.RevokeSession
|
||||
case ChallengeReason.DecryptEncryptedFile:
|
||||
return ChallengeStrings.DecryptEncryptedFile
|
||||
case ChallengeReason.ExportBackup:
|
||||
return ChallengeStrings.ExportBackup
|
||||
case ChallengeReason.DisableBiometrics:
|
||||
return ChallengeStrings.DisableBiometrics
|
||||
case ChallengeReason.UnprotectNote:
|
||||
return ChallengeStrings.UnprotectNote
|
||||
case ChallengeReason.UnprotectFile:
|
||||
return ChallengeStrings.UnprotectFile
|
||||
case ChallengeReason.SearchProtectedNotesText:
|
||||
return ChallengeStrings.SearchProtectedNotesText
|
||||
case ChallengeReason.SelectProtectedNote:
|
||||
return ChallengeStrings.SelectProtectedNote
|
||||
case ChallengeReason.DisableMfa:
|
||||
return ChallengeStrings.DisableMfa
|
||||
case ChallengeReason.DeleteAccount:
|
||||
return ChallengeStrings.DeleteAccount
|
||||
case ChallengeReason.AuthorizeNoteForListed:
|
||||
return ChallengeStrings.ListedAuthorization
|
||||
case ChallengeReason.Custom:
|
||||
return ''
|
||||
default:
|
||||
return assertUnreachable(this.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Inside of the modal, this is the H2 */
|
||||
get subheading(): string | undefined {
|
||||
if (this._subheading) {
|
||||
return this._subheading
|
||||
}
|
||||
|
||||
switch (this.reason) {
|
||||
case ChallengeReason.Migration:
|
||||
return ChallengeStrings.EnterPasscodeForMigration
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
hasPromptForValidationType(type: ChallengeValidation): boolean {
|
||||
for (const prompt of this.prompts) {
|
||||
if (prompt.validation === type) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { RootKeyInterface } from '@standardnotes/models'
|
||||
|
||||
import { AbstractService } from '../Service/AbstractService'
|
||||
import { ChallengeInterface } from './ChallengeInterface'
|
||||
import { ChallengePromptInterface } from './Prompt/ChallengePromptInterface'
|
||||
@@ -10,7 +12,6 @@ export interface ChallengeServiceInterface extends AbstractService {
|
||||
* For non-validated challenges, will resolve when the first value is submitted.
|
||||
*/
|
||||
promptForChallengeResponse(challenge: ChallengeInterface): Promise<ChallengeResponseInterface | undefined>
|
||||
|
||||
createChallenge(
|
||||
prompts: ChallengePromptInterface[],
|
||||
reason: ChallengeReason,
|
||||
@@ -18,6 +19,19 @@ export interface ChallengeServiceInterface extends AbstractService {
|
||||
heading?: string,
|
||||
subheading?: string,
|
||||
): ChallengeInterface
|
||||
|
||||
completeChallenge(challenge: ChallengeInterface): void
|
||||
getWrappingKeyIfApplicable(passcode?: string): Promise<
|
||||
| {
|
||||
canceled?: undefined
|
||||
wrappingKey?: undefined
|
||||
}
|
||||
| {
|
||||
canceled: boolean
|
||||
wrappingKey?: undefined
|
||||
}
|
||||
| {
|
||||
wrappingKey: RootKeyInterface
|
||||
canceled?: undefined
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './Challenge'
|
||||
export * from './ChallengeInterface'
|
||||
export * from './ChallengeResponseInterface'
|
||||
export * from './ChallengeServiceInterface'
|
||||
|
||||
@@ -44,50 +44,22 @@ export interface ItemManagerInterface extends AbstractService {
|
||||
contentType: ContentType | ContentType[],
|
||||
callback: ItemManagerChangeObserverCallback<I>,
|
||||
): () => void
|
||||
|
||||
/**
|
||||
* Marks the item as deleted and needing sync.
|
||||
*/
|
||||
setItemToBeDeleted(itemToLookupUuidFor: DecryptedItemInterface, source?: PayloadEmitSource): Promise<void>
|
||||
|
||||
setItemsToBeDeleted(itemsToLookupUuidsFor: DecryptedItemInterface[]): Promise<void>
|
||||
|
||||
setItemsDirty(
|
||||
itemsToLookupUuidsFor: DecryptedItemInterface[],
|
||||
isUserModified?: boolean,
|
||||
): Promise<DecryptedItemInterface[]>
|
||||
|
||||
get items(): DecryptedItemInterface[]
|
||||
|
||||
/**
|
||||
* Inserts the item as-is by reading its payload value. This function will not
|
||||
* modify item in any way (such as marking it as dirty). It is up to the caller
|
||||
* to pass in a dirtied item if that is their intention.
|
||||
*/
|
||||
insertItem(item: DecryptedItemInterface): Promise<DecryptedItemInterface>
|
||||
|
||||
emitItemFromPayload(payload: DecryptedPayloadInterface, source: PayloadEmitSource): Promise<DecryptedItemInterface>
|
||||
|
||||
getItems<T extends DecryptedItemInterface>(contentType: ContentType | ContentType[]): T[]
|
||||
|
||||
/**
|
||||
* Returns all non-deleted items keys
|
||||
*/
|
||||
getDisplayableItemsKeys(): ItemsKeyInterface[]
|
||||
|
||||
/**
|
||||
* Creates an item and conditionally maps it and marks it as dirty.
|
||||
* @param needsSync - Whether to mark the item as needing sync
|
||||
*/
|
||||
createItem<T extends DecryptedItemInterface, C extends ItemContent = ItemContent>(
|
||||
contentType: ContentType,
|
||||
content: C,
|
||||
needsSync?: boolean,
|
||||
): Promise<T>
|
||||
|
||||
/**
|
||||
* Create an unmanaged item that can later be inserted via `insertItem`
|
||||
*/
|
||||
createTemplateItem<
|
||||
C extends ItemContent = ItemContent,
|
||||
I extends DecryptedItemInterface<C> = DecryptedItemInterface<C>,
|
||||
@@ -96,12 +68,6 @@ export interface ItemManagerInterface extends AbstractService {
|
||||
content?: C,
|
||||
override?: Partial<DecryptedPayload<C>>,
|
||||
): I
|
||||
|
||||
/**
|
||||
* Consumers wanting to modify an item should run it through this block,
|
||||
* so that data is properly mapped through our function, and latest state
|
||||
* is properly reconciled.
|
||||
*/
|
||||
changeItem<
|
||||
M extends DecryptedItemMutator = DecryptedItemMutator,
|
||||
I extends DecryptedItemInterface = DecryptedItemInterface,
|
||||
@@ -112,7 +78,6 @@ export interface ItemManagerInterface extends AbstractService {
|
||||
emitSource?: PayloadEmitSource,
|
||||
payloadSourceKey?: string,
|
||||
): Promise<I>
|
||||
|
||||
changeItemsKey(
|
||||
itemToLookupUuidFor: ItemsKeyInterface,
|
||||
mutate: (mutator: ItemsKeyMutatorInterface) => void,
|
||||
@@ -120,16 +85,15 @@ export interface ItemManagerInterface extends AbstractService {
|
||||
emitSource?: PayloadEmitSource,
|
||||
payloadSourceKey?: string,
|
||||
): Promise<ItemsKeyInterface>
|
||||
|
||||
itemsMatchingPredicate<T extends DecryptedItemInterface>(
|
||||
contentType: ContentType,
|
||||
predicate: PredicateInterface<T>,
|
||||
): T[]
|
||||
|
||||
itemsMatchingPredicates<T extends DecryptedItemInterface>(
|
||||
contentType: ContentType,
|
||||
predicates: PredicateInterface<T>[],
|
||||
): T[]
|
||||
|
||||
subItemsMatchingPredicates<T extends DecryptedItemInterface>(items: T[], predicates: PredicateInterface<T>[]): T[]
|
||||
removeAllItemsFromMemory(): Promise<void>
|
||||
getDirtyItems(): (DecryptedItemInterface | DeletedItemInterface)[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum MobileUnlockTiming {
|
||||
Immediately = 'immediately',
|
||||
OnQuit = 'on-quit',
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { DecryptedItem } from '@standardnotes/models'
|
||||
import { ChallengeReason } from '../Challenge'
|
||||
import { MobileUnlockTiming } from './MobileUnlockTiming'
|
||||
import { TimingDisplayOption } from './TimingDisplayOption'
|
||||
|
||||
export interface ProtectionsClientInterface {
|
||||
authorizeProtectedActionForItems<T extends DecryptedItem>(files: T[], challengeReason: ChallengeReason): Promise<T[]>
|
||||
authorizeItemAccess(item: DecryptedItem): Promise<boolean>
|
||||
getMobileBiometricsTiming(): MobileUnlockTiming | undefined
|
||||
getMobilePasscodeTiming(): MobileUnlockTiming | undefined
|
||||
setMobileBiometricsTiming(timing: MobileUnlockTiming): void
|
||||
setMobilePasscodeTiming(timing: MobileUnlockTiming): void
|
||||
setMobileScreenshotPrivacyEnabled(isEnabled: boolean): void
|
||||
getMobileScreenshotPrivacyEnabled(): boolean
|
||||
getMobilePasscodeTimingOptions(): TimingDisplayOption[]
|
||||
getMobileBiometricsTimingOptions(): TimingDisplayOption[]
|
||||
hasBiometricsEnabled(): boolean
|
||||
enableBiometrics(): boolean
|
||||
disableBiometrics(): Promise<boolean>
|
||||
authorizeAction(
|
||||
reason: ChallengeReason,
|
||||
dto: { fallBackToAccountPassword: boolean; requireAccountPassword: boolean; forcePrompt: boolean },
|
||||
): Promise<boolean>
|
||||
authorizeAddingPasscode(): Promise<boolean>
|
||||
authorizeRemovingPasscode(): Promise<boolean>
|
||||
authorizeChangingPasscode(): Promise<boolean>
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { MobileUnlockTiming } from './MobileUnlockTiming'
|
||||
|
||||
export type TimingDisplayOption = {
|
||||
title: string
|
||||
key: MobileUnlockTiming
|
||||
selected: boolean
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { AnyKeyParamsContent } from '@standardnotes/common'
|
||||
import { RootKeyInterface } from '@standardnotes/models'
|
||||
import { HttpResponse } from '@standardnotes/responses'
|
||||
|
||||
export type SessionManagerResponse = {
|
||||
response: HttpResponse
|
||||
rootKey?: RootKeyInterface
|
||||
keyParams?: AnyKeyParamsContent
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { UserRegistrationResponseBody } from '@standardnotes/api'
|
||||
import { ProtocolVersion } from '@standardnotes/common'
|
||||
import { RootKeyInterface } from '@standardnotes/models'
|
||||
import { ClientDisplayableError, HttpResponse, SignInResponse, User } from '@standardnotes/responses'
|
||||
import { Base64String } from '@standardnotes/sncrypto-common'
|
||||
|
||||
import { SessionManagerResponse } from './SessionManagerResponse'
|
||||
|
||||
export interface SessionsClientInterface {
|
||||
createDemoShareToken(): Promise<Base64String | ClientDisplayableError>
|
||||
populateSessionFromDemoShareToken(token: Base64String): Promise<void>
|
||||
getUser(): User | undefined
|
||||
register(email: string, password: string, ephemeral: boolean): Promise<UserRegistrationResponseBody>
|
||||
signIn(
|
||||
email: string,
|
||||
password: string,
|
||||
strict: boolean,
|
||||
ephemeral: boolean,
|
||||
minAllowedVersion?: ProtocolVersion,
|
||||
): Promise<SessionManagerResponse>
|
||||
getSureUser(): User
|
||||
bypassChecksAndSignInWithRootKey(
|
||||
email: string,
|
||||
rootKey: RootKeyInterface,
|
||||
ephemeral: boolean,
|
||||
): Promise<SignInResponse | HttpResponse>
|
||||
signOut(): Promise<void>
|
||||
changeCredentials(parameters: {
|
||||
currentServerPassword: string
|
||||
newRootKey: RootKeyInterface
|
||||
wrappingKey?: RootKeyInterface
|
||||
newEmail?: string
|
||||
}): Promise<SessionManagerResponse>
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
import { PayloadInterface, RootKeyInterface } from '@standardnotes/models'
|
||||
import { StorageValueModes } from './StorageTypes'
|
||||
import { FullyFormedPayloadInterface, PayloadInterface, RootKeyInterface } from '@standardnotes/models'
|
||||
import { StoragePersistencePolicies, StorageValueModes } from './StorageTypes'
|
||||
|
||||
export interface StorageServiceInterface {
|
||||
getValue<T>(key: string, mode?: StorageValueModes, defaultValue?: T): T
|
||||
|
||||
canDecryptWithKey(key: RootKeyInterface): Promise<boolean>
|
||||
|
||||
savePayload(payload: PayloadInterface): Promise<void>
|
||||
|
||||
savePayloads(decryptedPayloads: PayloadInterface[]): Promise<void>
|
||||
|
||||
setValue(key: string, value: unknown, mode?: StorageValueModes): void
|
||||
|
||||
removeValue(key: string, mode?: StorageValueModes): Promise<void>
|
||||
setPersistencePolicy(persistencePolicy: StoragePersistencePolicies): Promise<void>
|
||||
clearAllData(): Promise<void>
|
||||
forceDeletePayloads(payloads: FullyFormedPayloadInterface[]): Promise<void>
|
||||
clearAllPayloads(): Promise<void>
|
||||
}
|
||||
|
||||
11
packages/services/src/Domain/Strings/InfoStrings.ts
Normal file
11
packages/services/src/Domain/Strings/InfoStrings.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const InfoStrings = {
|
||||
AccountDeleted: 'Your account has been successfully deleted.',
|
||||
UnsupportedBackupFileVersion:
|
||||
'This backup file was created using a newer version of the application and cannot be imported here. Please update your application and try again.',
|
||||
BackupFileMoreRecentThanAccount:
|
||||
"This backup file was created using a newer encryption version than your account's. Please run the available encryption upgrade and try again.",
|
||||
SavingWhileDocumentHidden:
|
||||
'Attempting to save an item while the application is hidden. To protect data integrity, please refresh the application window and try again.',
|
||||
InvalidNote:
|
||||
"The note you are attempting to save can not be found or has been deleted. Changes you make will not be synced. Please copy this note's text and start a new note.",
|
||||
}
|
||||
186
packages/services/src/Domain/Strings/Messages.ts
Normal file
186
packages/services/src/Domain/Strings/Messages.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { ProtocolVersion } from '@standardnotes/common'
|
||||
|
||||
export const API_MESSAGE_GENERIC_INVALID_LOGIN = 'A server error occurred while trying to sign in. Please try again.'
|
||||
export const API_MESSAGE_GENERIC_REGISTRATION_FAIL =
|
||||
'A server error occurred while trying to register. Please try again.'
|
||||
export const API_MESSAGE_GENERIC_CHANGE_CREDENTIALS_FAIL =
|
||||
'Something went wrong while changing your credentials. Your credentials were not changed. Please try again.'
|
||||
export const API_MESSAGE_GENERIC_SYNC_FAIL = 'Could not connect to server.'
|
||||
|
||||
export const ServerErrorStrings = {
|
||||
DeleteAccountError: 'Your account was unable to be deleted due to an error. Please try your request again.',
|
||||
}
|
||||
|
||||
export const API_MESSAGE_GENERIC_INTEGRITY_CHECK_FAIL = 'Could not check your data integrity with the server.'
|
||||
|
||||
export const API_MESSAGE_GENERIC_SINGLE_ITEM_SYNC_FAIL = 'Could not retrieve item.'
|
||||
|
||||
export const API_MESSAGE_REGISTRATION_IN_PROGRESS = 'An existing registration request is already in progress.'
|
||||
export const API_MESSAGE_LOGIN_IN_PROGRESS = 'An existing sign in request is already in progress.'
|
||||
export const API_MESSAGE_CHANGE_CREDENTIALS_IN_PROGRESS =
|
||||
'An existing change credentials request is already in progress.'
|
||||
|
||||
export const API_MESSAGE_FALLBACK_LOGIN_FAIL = 'Invalid email or password.'
|
||||
|
||||
export const API_MESSAGE_GENERIC_TOKEN_REFRESH_FAIL =
|
||||
'A server error occurred while trying to refresh your session. Please try again.'
|
||||
|
||||
export const API_MESSAGE_TOKEN_REFRESH_IN_PROGRESS =
|
||||
'Your account session is being renewed with the server. Please try your request again.'
|
||||
|
||||
export const API_MESSAGE_RATE_LIMITED = 'Too many successive server requests. Please wait a few minutes and try again.'
|
||||
|
||||
export const API_MESSAGE_INVALID_SESSION = 'Please sign in to an account in order to continue with your request.'
|
||||
|
||||
export const API_MESSAGE_FAILED_GET_SETTINGS = 'Failed to get settings.'
|
||||
export const API_MESSAGE_FAILED_UPDATE_SETTINGS = 'Failed to update settings.'
|
||||
export const API_MESSAGE_FAILED_LISTED_REGISTRATION = 'Unable to register for Listed. Please try again later.'
|
||||
|
||||
export const API_MESSAGE_FAILED_CREATE_FILE_TOKEN = 'Failed to create file token.'
|
||||
|
||||
export const API_MESSAGE_FAILED_SUBSCRIPTION_INFO = "Failed to get subscription's information."
|
||||
|
||||
export const API_MESSAGE_FAILED_ACCESS_PURCHASE = 'Failed to access purchase flow.'
|
||||
|
||||
export const API_MESSAGE_FAILED_DELETE_REVISION = 'Failed to delete revision.'
|
||||
|
||||
export const API_MESSAGE_FAILED_OFFLINE_FEATURES = 'Failed to get offline features.'
|
||||
export const API_MESSAGE_UNTRUSTED_EXTENSIONS_WARNING = `The extension you are attempting to install comes from an
|
||||
untrusted source. Untrusted extensions may lower the security of your data. Do you want to continue?`
|
||||
export const API_MESSAGE_FAILED_DOWNLOADING_EXTENSION = `Error downloading package details. Please check the
|
||||
extension link and try again.`
|
||||
export const API_MESSAGE_FAILED_OFFLINE_ACTIVATION =
|
||||
'An unknown issue occurred during offline activation. Please try again.'
|
||||
|
||||
export const INVALID_EXTENSION_URL = 'Invalid extension URL.'
|
||||
|
||||
export const UNSUPPORTED_PROTOCOL_VERSION =
|
||||
'This version of the application does not support your newer account type. Please upgrade to the latest version of Standard Notes to sign in.'
|
||||
|
||||
export const EXPIRED_PROTOCOL_VERSION =
|
||||
'The protocol version associated with your account is outdated and no longer supported by this application. Please visit standardnotes.com/help/security for more information.'
|
||||
|
||||
export const UNSUPPORTED_KEY_DERIVATION =
|
||||
'Your account was created on a platform with higher security capabilities than this browser supports. If we attempted to generate your login keys here, it would take hours. Please use a browser with more up to date security capabilities, like Google Chrome or Firefox, to log in.'
|
||||
|
||||
export const INVALID_PASSWORD_COST =
|
||||
'Unable to sign in due to insecure password parameters. Please visit standardnotes.com/help/security for more information.'
|
||||
export const INVALID_PASSWORD = 'Invalid password.'
|
||||
|
||||
export const OUTDATED_PROTOCOL_ALERT_IGNORE = 'Sign In'
|
||||
export const UPGRADING_ENCRYPTION = "Upgrading your account's encryption version…"
|
||||
|
||||
export const SETTING_PASSCODE = 'Setting passcode…'
|
||||
export const CHANGING_PASSCODE = 'Changing passcode…'
|
||||
export const REMOVING_PASSCODE = 'Removing passcode…'
|
||||
|
||||
export const DO_NOT_CLOSE_APPLICATION = 'Do not close the application until this process completes.'
|
||||
|
||||
export const UNKNOWN_ERROR = 'Unknown error.'
|
||||
|
||||
export function InsufficientPasswordMessage(minimum: number): string {
|
||||
return `Your password must be at least ${minimum} characters in length. For your security, please choose a longer password or, ideally, a passphrase, and try again.`
|
||||
}
|
||||
|
||||
export function StrictSignInFailed(current: ProtocolVersion, latest: ProtocolVersion): string {
|
||||
return `Strict Sign In has refused the server's sign-in parameters. The latest account version is ${latest}, but the server is reporting a version of ${current} for your account. If you'd like to proceed with sign in anyway, please disable Strict Sign In and try again.`
|
||||
}
|
||||
|
||||
export const CredentialsChangeStrings = {
|
||||
PasscodeRequired: 'Your passcode is required to process your credentials change.',
|
||||
Failed: 'Unable to change your credentials due to a sync error. Please try again.',
|
||||
}
|
||||
|
||||
export const RegisterStrings = {
|
||||
PasscodeRequired: 'Your passcode is required in order to register for an account.',
|
||||
}
|
||||
|
||||
export const SignInStrings = {
|
||||
PasscodeRequired: 'Your passcode is required in order to sign in to your account.',
|
||||
IncorrectMfa: 'Incorrect two-factor authentication code. Please try again.',
|
||||
SignInCanceledMissingMfa: 'Your sign in request has been canceled.',
|
||||
}
|
||||
|
||||
export const ProtocolUpgradeStrings = {
|
||||
SuccessAccount:
|
||||
"Your encryption version has been successfully upgraded. You may be asked to enter your credentials again on other devices you're signed into.",
|
||||
SuccessPasscodeOnly: 'Your encryption version has been successfully upgraded.',
|
||||
Fail: 'Unable to upgrade encryption version. Please try again.',
|
||||
UpgradingPasscode: 'Upgrading local encryption...',
|
||||
}
|
||||
|
||||
export const ChallengeModalTitle = {
|
||||
Generic: 'Authentication Required',
|
||||
Migration: 'Storage Update',
|
||||
}
|
||||
|
||||
export const SessionStrings = {
|
||||
EnterEmailAndPassword: 'Please enter your account email and password.',
|
||||
RecoverSession(email?: string): string {
|
||||
return email
|
||||
? `Your credentials are needed for ${email} to refresh your session with the server.`
|
||||
: 'Your credentials are needed to refresh your session with the server.'
|
||||
},
|
||||
SessionRestored: 'Your session has been successfully restored.',
|
||||
EnterMfa: 'Please enter your two-factor authentication code.',
|
||||
MfaInputPlaceholder: 'Two-factor authentication code',
|
||||
EmailInputPlaceholder: 'Email',
|
||||
PasswordInputPlaceholder: 'Password',
|
||||
KeychainRecoveryErrorTitle: 'Invalid Credentials',
|
||||
KeychainRecoveryError:
|
||||
'The email or password you entered is incorrect.\n\nPlease note that this sign-in request is made against the default server. If you are using a custom server, you must uninstall the app then reinstall, and sign back into your account.',
|
||||
RevokeTitle: 'Revoke this session?',
|
||||
RevokeConfirmButton: 'Revoke',
|
||||
RevokeCancelButton: 'Cancel',
|
||||
RevokeText:
|
||||
'The associated app will be signed out and all data removed ' +
|
||||
'from the device when it is next launched. You can sign back in on that ' +
|
||||
'device at any time.',
|
||||
CurrentSessionRevoked: 'Your session has been revoked and all local data has been removed ' + 'from this device.',
|
||||
}
|
||||
|
||||
export const ChallengeStrings = {
|
||||
UnlockApplication: 'Authentication is required to unlock the application',
|
||||
NoteAccess: 'Authentication is required to view this note',
|
||||
FileAccess: 'Authentication is required to access this file',
|
||||
ImportFile: 'Authentication is required to import a backup file',
|
||||
AddPasscode: 'Authentication is required to add a passcode',
|
||||
RemovePasscode: 'Authentication is required to remove your passcode',
|
||||
ChangePasscode: 'Authentication is required to change your passcode',
|
||||
ChangeAutolockInterval: 'Authentication is required to change autolock timer duration',
|
||||
RevokeSession: 'Authentication is required to revoke a session',
|
||||
EnterAccountPassword: 'Enter your account password',
|
||||
EnterLocalPasscode: 'Enter your application passcode',
|
||||
EnterPasscodeForMigration:
|
||||
'Your application passcode is required to perform an upgrade of your local data storage structure.',
|
||||
EnterPasscodeForRootResave: 'Enter your application passcode to continue',
|
||||
EnterCredentialsForProtocolUpgrade: 'Enter your credentials to perform encryption upgrade',
|
||||
EnterCredentialsForDecryptedBackupDownload: 'Enter your credentials to download a decrypted backup',
|
||||
AccountPasswordPlaceholder: 'Account Password',
|
||||
LocalPasscodePlaceholder: 'Application Passcode',
|
||||
DecryptEncryptedFile: 'Enter the account password associated with the import file',
|
||||
ExportBackup: 'Authentication is required to export a backup',
|
||||
DisableBiometrics: 'Authentication is required to disable biometrics',
|
||||
UnprotectNote: 'Authentication is required to unprotect a note',
|
||||
UnprotectFile: 'Authentication is required to unprotect a file',
|
||||
SearchProtectedNotesText: 'Authentication is required to search protected contents',
|
||||
SelectProtectedNote: 'Authentication is required to select a protected note',
|
||||
DisableMfa: 'Authentication is required to disable two-factor authentication',
|
||||
DeleteAccount: 'Authentication is required to delete your account',
|
||||
ListedAuthorization: 'Authentication is required to approve this note for Listed',
|
||||
}
|
||||
|
||||
export const ErrorAlertStrings = {
|
||||
MissingSessionTitle: 'Missing Session',
|
||||
MissingSessionBody:
|
||||
'We were unable to load your server session. This represents an inconsistency with your application state. Please take an opportunity to backup your data, then sign out and sign back in to resolve this issue.',
|
||||
|
||||
StorageDecryptErrorTitle: 'Storage Error',
|
||||
StorageDecryptErrorBody:
|
||||
"We were unable to decrypt your local storage. Please restart the app and try again. If you're unable to resolve this issue, and you have an account, you may try uninstalling the app then reinstalling, then signing back into your account. Otherwise, please contact help@standardnotes.org for support.",
|
||||
}
|
||||
|
||||
export const KeychainRecoveryStrings = {
|
||||
Title: 'Restore Keychain',
|
||||
Text: "We've detected that your keychain has been wiped. This can happen when restoring your device from a backup. Please enter your account password to restore your account keys.",
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
import { FullyFormedPayloadInterface } from '@standardnotes/models'
|
||||
import { SyncOptions } from './SyncOptions'
|
||||
|
||||
export interface SyncServiceInterface {
|
||||
sync(options?: Partial<SyncOptions>): Promise<unknown>
|
||||
resetSyncState(): void
|
||||
markAllItemsAsNeedingSyncAndPersist(): Promise<void>
|
||||
downloadFirstSync(waitTimeOnFailureMs: number, otherSyncOptions?: Partial<SyncOptions>): Promise<void>
|
||||
persistPayloads(payloads: FullyFormedPayloadInterface[]): Promise<void>
|
||||
lockSyncing(): void
|
||||
unlockSyncing(): void
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { DeinitSource } from './DeinitSource'
|
||||
import { DeinitSource } from '../Application/DeinitSource'
|
||||
|
||||
export interface UserClientInterface {
|
||||
deleteAccount(): Promise<{
|
||||
error: boolean
|
||||
message?: string
|
||||
}>
|
||||
|
||||
signOut(force?: boolean, source?: DeinitSource): Promise<void>
|
||||
}
|
||||
593
packages/services/src/Domain/User/UserService.ts
Normal file
593
packages/services/src/Domain/User/UserService.ts
Normal file
@@ -0,0 +1,593 @@
|
||||
import { EncryptionProviderInterface, SNRootKey, SNRootKeyParams } from '@standardnotes/encryption'
|
||||
import { HttpResponse, SignInResponse, User } from '@standardnotes/responses'
|
||||
import { KeyParamsOrigination } from '@standardnotes/common'
|
||||
import { UuidGenerator } from '@standardnotes/utils'
|
||||
import { UserApiServiceInterface, UserRegistrationResponseBody } from '@standardnotes/api'
|
||||
|
||||
import * as Messages from '../Strings/Messages'
|
||||
import { InfoStrings } from '../Strings/InfoStrings'
|
||||
import { SyncServiceInterface } from '../Sync/SyncServiceInterface'
|
||||
import { StorageServiceInterface } from '../Storage/StorageServiceInterface'
|
||||
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
|
||||
import { AlertService } from '../Alert/AlertService'
|
||||
import {
|
||||
Challenge,
|
||||
ChallengePrompt,
|
||||
ChallengeReason,
|
||||
ChallengeServiceInterface,
|
||||
ChallengeValidation,
|
||||
} from '../Challenge'
|
||||
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
|
||||
import { AbstractService } from '../Service/AbstractService'
|
||||
import { UserClientInterface } from './UserClientInterface'
|
||||
import { DeinitSource } from '../Application/DeinitSource'
|
||||
import { StoragePersistencePolicies } from '../Storage/StorageTypes'
|
||||
import { SessionsClientInterface } from '../Session/SessionsClientInterface'
|
||||
import { ProtectionsClientInterface } from '../Protection/ProtectionClientInterface'
|
||||
|
||||
export type CredentialsChangeFunctionResponse = { error?: { message: string } }
|
||||
export type AccountServiceResponse = HttpResponse
|
||||
|
||||
export enum AccountEvent {
|
||||
SignedInOrRegistered = 'SignedInOrRegistered',
|
||||
SignedOut = 'SignedOut',
|
||||
}
|
||||
|
||||
type AccountEventData = {
|
||||
source: DeinitSource
|
||||
}
|
||||
|
||||
export class UserService extends AbstractService<AccountEvent, AccountEventData> implements UserClientInterface {
|
||||
private signingIn = false
|
||||
private registering = false
|
||||
|
||||
private readonly MINIMUM_PASSCODE_LENGTH = 1
|
||||
private readonly MINIMUM_PASSWORD_LENGTH = 8
|
||||
|
||||
constructor(
|
||||
private sessionManager: SessionsClientInterface,
|
||||
private syncService: SyncServiceInterface,
|
||||
private storageService: StorageServiceInterface,
|
||||
private itemManager: ItemManagerInterface,
|
||||
private protocolService: EncryptionProviderInterface,
|
||||
private alertService: AlertService,
|
||||
private challengeService: ChallengeServiceInterface,
|
||||
private protectionService: ProtectionsClientInterface,
|
||||
private userApiService: UserApiServiceInterface,
|
||||
protected override internalEventBus: InternalEventBusInterface,
|
||||
) {
|
||||
super(internalEventBus)
|
||||
}
|
||||
|
||||
public override deinit(): void {
|
||||
super.deinit()
|
||||
;(this.sessionManager as unknown) = undefined
|
||||
;(this.syncService as unknown) = undefined
|
||||
;(this.storageService as unknown) = undefined
|
||||
;(this.itemManager as unknown) = undefined
|
||||
;(this.protocolService as unknown) = undefined
|
||||
;(this.alertService as unknown) = undefined
|
||||
;(this.challengeService as unknown) = undefined
|
||||
;(this.protectionService as unknown) = undefined
|
||||
;(this.userApiService as unknown) = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mergeLocal Whether to merge existing offline data into account. If false,
|
||||
* any pre-existing data will be fully deleted upon success.
|
||||
*/
|
||||
public async register(
|
||||
email: string,
|
||||
password: string,
|
||||
ephemeral = false,
|
||||
mergeLocal = true,
|
||||
): Promise<UserRegistrationResponseBody> {
|
||||
if (this.protocolService.hasAccount()) {
|
||||
throw Error('Tried to register when an account already exists.')
|
||||
}
|
||||
|
||||
if (this.registering) {
|
||||
throw Error('Already registering.')
|
||||
}
|
||||
|
||||
this.registering = true
|
||||
|
||||
try {
|
||||
this.lockSyncing()
|
||||
const response = await this.sessionManager.register(email, password, ephemeral)
|
||||
|
||||
this.syncService.resetSyncState()
|
||||
|
||||
await this.storageService.setPersistencePolicy(
|
||||
ephemeral ? StoragePersistencePolicies.Ephemeral : StoragePersistencePolicies.Default,
|
||||
)
|
||||
|
||||
if (mergeLocal) {
|
||||
await this.syncService.markAllItemsAsNeedingSyncAndPersist()
|
||||
} else {
|
||||
await this.itemManager.removeAllItemsFromMemory()
|
||||
await this.clearDatabase()
|
||||
}
|
||||
|
||||
await this.notifyEvent(AccountEvent.SignedInOrRegistered)
|
||||
|
||||
this.unlockSyncing()
|
||||
this.registering = false
|
||||
|
||||
await this.syncService.downloadFirstSync(300)
|
||||
void this.protocolService.decryptErroredPayloads()
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
this.unlockSyncing()
|
||||
this.registering = false
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mergeLocal Whether to merge existing offline data into account.
|
||||
* If false, any pre-existing data will be fully deleted upon success.
|
||||
*/
|
||||
public async signIn(
|
||||
email: string,
|
||||
password: string,
|
||||
strict = false,
|
||||
ephemeral = false,
|
||||
mergeLocal = true,
|
||||
awaitSync = false,
|
||||
): Promise<AccountServiceResponse> {
|
||||
if (this.protocolService.hasAccount()) {
|
||||
throw Error('Tried to sign in when an account already exists.')
|
||||
}
|
||||
|
||||
if (this.signingIn) {
|
||||
throw Error('Already signing in.')
|
||||
}
|
||||
|
||||
this.signingIn = true
|
||||
|
||||
try {
|
||||
/** Prevent a timed sync from occuring while signing in. */
|
||||
this.lockSyncing()
|
||||
|
||||
const result = await this.sessionManager.signIn(email, password, strict, ephemeral)
|
||||
|
||||
if (!result.response.error) {
|
||||
this.syncService.resetSyncState()
|
||||
|
||||
await this.storageService.setPersistencePolicy(
|
||||
ephemeral ? StoragePersistencePolicies.Ephemeral : StoragePersistencePolicies.Default,
|
||||
)
|
||||
|
||||
if (mergeLocal) {
|
||||
await this.syncService.markAllItemsAsNeedingSyncAndPersist()
|
||||
} else {
|
||||
void this.itemManager.removeAllItemsFromMemory()
|
||||
await this.clearDatabase()
|
||||
}
|
||||
|
||||
await this.notifyEvent(AccountEvent.SignedInOrRegistered)
|
||||
|
||||
this.unlockSyncing()
|
||||
|
||||
const syncPromise = this.syncService
|
||||
.downloadFirstSync(1_000, {
|
||||
checkIntegrity: true,
|
||||
awaitAll: awaitSync,
|
||||
})
|
||||
.then(() => {
|
||||
if (!awaitSync) {
|
||||
void this.protocolService.decryptErroredPayloads()
|
||||
}
|
||||
})
|
||||
|
||||
if (awaitSync) {
|
||||
await syncPromise
|
||||
|
||||
await this.protocolService.decryptErroredPayloads()
|
||||
}
|
||||
} else {
|
||||
this.unlockSyncing()
|
||||
}
|
||||
|
||||
return result.response
|
||||
} finally {
|
||||
this.signingIn = false
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteAccount(): Promise<{
|
||||
error: boolean
|
||||
message?: string
|
||||
}> {
|
||||
if (
|
||||
!(await this.protectionService.authorizeAction(ChallengeReason.DeleteAccount, {
|
||||
fallBackToAccountPassword: true,
|
||||
requireAccountPassword: true,
|
||||
forcePrompt: false,
|
||||
}))
|
||||
) {
|
||||
return {
|
||||
error: true,
|
||||
message: Messages.INVALID_PASSWORD,
|
||||
}
|
||||
}
|
||||
|
||||
const uuid = this.sessionManager.getSureUser().uuid
|
||||
const response = await this.userApiService.deleteAccount(uuid)
|
||||
if (response.data.error) {
|
||||
return {
|
||||
error: true,
|
||||
message: response.data.error.message,
|
||||
}
|
||||
}
|
||||
|
||||
await this.signOut(true)
|
||||
|
||||
void this.alertService.alert(InfoStrings.AccountDeleted)
|
||||
|
||||
return {
|
||||
error: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A sign in request that occurs while the user was previously signed in, to correct
|
||||
* for missing keys or storage values. Unlike regular sign in, this doesn't worry about
|
||||
* performing one of marking all items as needing sync or deleting all local data.
|
||||
*/
|
||||
public async correctiveSignIn(rootKey: SNRootKey): Promise<HttpResponse | SignInResponse> {
|
||||
this.lockSyncing()
|
||||
|
||||
const response = await this.sessionManager.bypassChecksAndSignInWithRootKey(
|
||||
rootKey.keyParams.identifier,
|
||||
rootKey,
|
||||
false,
|
||||
)
|
||||
|
||||
if (!response.error) {
|
||||
await this.notifyEvent(AccountEvent.SignedInOrRegistered)
|
||||
|
||||
this.unlockSyncing()
|
||||
|
||||
void this.syncService.downloadFirstSync(1_000, {
|
||||
checkIntegrity: true,
|
||||
})
|
||||
|
||||
void this.protocolService.decryptErroredPayloads()
|
||||
}
|
||||
|
||||
this.unlockSyncing()
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* @param passcode - Changing the account password or email requires the local
|
||||
* passcode if configured (to rewrap the account key with passcode). If the passcode
|
||||
* is not passed in, the user will be prompted for the passcode. However if the consumer
|
||||
* already has reference to the passcode, they can pass it in here so that the user
|
||||
* is not prompted again.
|
||||
*/
|
||||
public async changeCredentials(parameters: {
|
||||
currentPassword: string
|
||||
origination: KeyParamsOrigination
|
||||
validateNewPasswordStrength: boolean
|
||||
newEmail?: string
|
||||
newPassword?: string
|
||||
passcode?: string
|
||||
}): Promise<CredentialsChangeFunctionResponse> {
|
||||
const result = await this.performCredentialsChange(parameters)
|
||||
if (result.error) {
|
||||
void this.alertService.alert(result.error.message)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
public async signOut(force = false, source = DeinitSource.SignOut): Promise<void> {
|
||||
const performSignOut = async () => {
|
||||
await this.sessionManager.signOut()
|
||||
await this.protocolService.deleteWorkspaceSpecificKeyStateFromDevice()
|
||||
await this.storageService.clearAllData()
|
||||
await this.notifyEvent(AccountEvent.SignedOut, { source })
|
||||
}
|
||||
|
||||
if (force) {
|
||||
await performSignOut()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const dirtyItems = this.itemManager.getDirtyItems()
|
||||
if (dirtyItems.length > 0) {
|
||||
const singular = dirtyItems.length === 1
|
||||
const didConfirm = await this.alertService.confirm(
|
||||
`There ${singular ? 'is' : 'are'} ${dirtyItems.length} ${
|
||||
singular ? 'item' : 'items'
|
||||
} with unsynced changes. If you sign out, these changes will be lost forever. Are you sure you want to sign out?`,
|
||||
)
|
||||
if (didConfirm) {
|
||||
await performSignOut()
|
||||
}
|
||||
} else {
|
||||
await performSignOut()
|
||||
}
|
||||
}
|
||||
|
||||
public async performProtocolUpgrade(): Promise<{
|
||||
success?: true
|
||||
canceled?: true
|
||||
error?: { message: string }
|
||||
}> {
|
||||
const hasPasscode = this.protocolService.hasPasscode()
|
||||
const hasAccount = this.protocolService.hasAccount()
|
||||
const prompts = []
|
||||
if (hasPasscode) {
|
||||
prompts.push(
|
||||
new ChallengePrompt(
|
||||
ChallengeValidation.LocalPasscode,
|
||||
undefined,
|
||||
Messages.ChallengeStrings.LocalPasscodePlaceholder,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hasAccount) {
|
||||
prompts.push(
|
||||
new ChallengePrompt(
|
||||
ChallengeValidation.AccountPassword,
|
||||
undefined,
|
||||
Messages.ChallengeStrings.AccountPasswordPlaceholder,
|
||||
),
|
||||
)
|
||||
}
|
||||
const challenge = new Challenge(prompts, ChallengeReason.ProtocolUpgrade, true)
|
||||
const response = await this.challengeService.promptForChallengeResponse(challenge)
|
||||
if (!response) {
|
||||
return { canceled: true }
|
||||
}
|
||||
const dismissBlockingDialog = await this.alertService.blockingDialog(
|
||||
Messages.DO_NOT_CLOSE_APPLICATION,
|
||||
Messages.UPGRADING_ENCRYPTION,
|
||||
)
|
||||
try {
|
||||
let passcode: string | undefined
|
||||
if (hasPasscode) {
|
||||
/* Upgrade passcode version */
|
||||
const value = response.getValueForType(ChallengeValidation.LocalPasscode)
|
||||
passcode = value.value as string
|
||||
}
|
||||
if (hasAccount) {
|
||||
/* Upgrade account version */
|
||||
const value = response.getValueForType(ChallengeValidation.AccountPassword)
|
||||
const password = value.value as string
|
||||
const changeResponse = await this.changeCredentials({
|
||||
currentPassword: password,
|
||||
newPassword: password,
|
||||
passcode,
|
||||
origination: KeyParamsOrigination.ProtocolUpgrade,
|
||||
validateNewPasswordStrength: false,
|
||||
})
|
||||
if (changeResponse?.error) {
|
||||
return { error: changeResponse.error }
|
||||
}
|
||||
}
|
||||
if (hasPasscode) {
|
||||
/* Upgrade passcode version */
|
||||
await this.removePasscodeWithoutWarning()
|
||||
await this.setPasscodeWithoutWarning(passcode as string, KeyParamsOrigination.ProtocolUpgrade)
|
||||
}
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { error: error as Error }
|
||||
} finally {
|
||||
dismissBlockingDialog()
|
||||
}
|
||||
}
|
||||
|
||||
public async addPasscode(passcode: string): Promise<boolean> {
|
||||
if (passcode.length < this.MINIMUM_PASSCODE_LENGTH) {
|
||||
return false
|
||||
}
|
||||
if (!(await this.protectionService.authorizeAddingPasscode())) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dismissBlockingDialog = await this.alertService.blockingDialog(
|
||||
Messages.DO_NOT_CLOSE_APPLICATION,
|
||||
Messages.SETTING_PASSCODE,
|
||||
)
|
||||
try {
|
||||
await this.setPasscodeWithoutWarning(passcode, KeyParamsOrigination.PasscodeCreate)
|
||||
return true
|
||||
} finally {
|
||||
dismissBlockingDialog()
|
||||
}
|
||||
}
|
||||
|
||||
public async removePasscode(): Promise<boolean> {
|
||||
if (!(await this.protectionService.authorizeRemovingPasscode())) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dismissBlockingDialog = await this.alertService.blockingDialog(
|
||||
Messages.DO_NOT_CLOSE_APPLICATION,
|
||||
Messages.REMOVING_PASSCODE,
|
||||
)
|
||||
try {
|
||||
await this.removePasscodeWithoutWarning()
|
||||
return true
|
||||
} finally {
|
||||
dismissBlockingDialog()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns whether the passcode was successfuly changed or not
|
||||
*/
|
||||
public async changePasscode(
|
||||
newPasscode: string,
|
||||
origination = KeyParamsOrigination.PasscodeChange,
|
||||
): Promise<boolean> {
|
||||
if (newPasscode.length < this.MINIMUM_PASSCODE_LENGTH) {
|
||||
return false
|
||||
}
|
||||
if (!(await this.protectionService.authorizeChangingPasscode())) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dismissBlockingDialog = await this.alertService.blockingDialog(
|
||||
Messages.DO_NOT_CLOSE_APPLICATION,
|
||||
origination === KeyParamsOrigination.ProtocolUpgrade
|
||||
? Messages.ProtocolUpgradeStrings.UpgradingPasscode
|
||||
: Messages.CHANGING_PASSCODE,
|
||||
)
|
||||
try {
|
||||
await this.removePasscodeWithoutWarning()
|
||||
await this.setPasscodeWithoutWarning(newPasscode, origination)
|
||||
return true
|
||||
} finally {
|
||||
dismissBlockingDialog()
|
||||
}
|
||||
}
|
||||
|
||||
private async setPasscodeWithoutWarning(passcode: string, origination: KeyParamsOrigination) {
|
||||
const identifier = UuidGenerator.GenerateUuid()
|
||||
const key = await this.protocolService.createRootKey(identifier, passcode, origination)
|
||||
await this.protocolService.setNewRootKeyWrapper(key)
|
||||
await this.rewriteItemsKeys()
|
||||
await this.syncService.sync()
|
||||
}
|
||||
|
||||
private async removePasscodeWithoutWarning() {
|
||||
await this.protocolService.removePasscode()
|
||||
await this.rewriteItemsKeys()
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows items keys to be rewritten to local db on local credential status change,
|
||||
* such as if passcode is added, changed, or removed.
|
||||
* This allows IndexedDB unencrypted logs to be deleted
|
||||
* `deletePayloads` will remove data from backing store,
|
||||
* but not from working memory See:
|
||||
* https://github.com/standardnotes/desktop/issues/131
|
||||
*/
|
||||
private async rewriteItemsKeys(): Promise<void> {
|
||||
const itemsKeys = this.itemManager.getDisplayableItemsKeys()
|
||||
const payloads = itemsKeys.map((key) => key.payloadRepresentation())
|
||||
await this.storageService.forceDeletePayloads(payloads)
|
||||
await this.syncService.persistPayloads(payloads)
|
||||
}
|
||||
|
||||
private lockSyncing(): void {
|
||||
this.syncService.lockSyncing()
|
||||
}
|
||||
|
||||
private unlockSyncing(): void {
|
||||
this.syncService.unlockSyncing()
|
||||
}
|
||||
|
||||
private clearDatabase(): Promise<void> {
|
||||
return this.storageService.clearAllPayloads()
|
||||
}
|
||||
|
||||
private async performCredentialsChange(parameters: {
|
||||
currentPassword: string
|
||||
origination: KeyParamsOrigination
|
||||
validateNewPasswordStrength: boolean
|
||||
newEmail?: string
|
||||
newPassword?: string
|
||||
passcode?: string
|
||||
}): Promise<CredentialsChangeFunctionResponse> {
|
||||
const { wrappingKey, canceled } = await this.challengeService.getWrappingKeyIfApplicable(parameters.passcode)
|
||||
|
||||
if (canceled) {
|
||||
return { error: Error(Messages.CredentialsChangeStrings.PasscodeRequired) }
|
||||
}
|
||||
|
||||
if (parameters.newPassword !== undefined && parameters.validateNewPasswordStrength) {
|
||||
if (parameters.newPassword.length < this.MINIMUM_PASSWORD_LENGTH) {
|
||||
return {
|
||||
error: Error(Messages.InsufficientPasswordMessage(this.MINIMUM_PASSWORD_LENGTH)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const accountPasswordValidation = await this.protocolService.validateAccountPassword(parameters.currentPassword)
|
||||
if (!accountPasswordValidation.valid) {
|
||||
return {
|
||||
error: Error(Messages.INVALID_PASSWORD),
|
||||
}
|
||||
}
|
||||
|
||||
const user = this.sessionManager.getUser() as User
|
||||
const currentEmail = user.email
|
||||
const rootKeys = await this.recomputeRootKeysForCredentialChange({
|
||||
currentPassword: parameters.currentPassword,
|
||||
currentEmail,
|
||||
origination: parameters.origination,
|
||||
newEmail: parameters.newEmail,
|
||||
newPassword: parameters.newPassword,
|
||||
})
|
||||
|
||||
this.lockSyncing()
|
||||
|
||||
/** Now, change the credentials on the server. Roll back on failure */
|
||||
const result = await this.sessionManager.changeCredentials({
|
||||
currentServerPassword: rootKeys.currentRootKey.serverPassword as string,
|
||||
newRootKey: rootKeys.newRootKey,
|
||||
wrappingKey,
|
||||
newEmail: parameters.newEmail,
|
||||
})
|
||||
|
||||
this.unlockSyncing()
|
||||
|
||||
if (!result.response.error) {
|
||||
const rollback = await this.protocolService.createNewItemsKeyWithRollback()
|
||||
await this.protocolService.reencryptItemsKeys()
|
||||
await this.syncService.sync({ awaitAll: true })
|
||||
|
||||
const defaultItemsKey = this.protocolService.getSureDefaultItemsKey()
|
||||
const itemsKeyWasSynced = !defaultItemsKey.neverSynced
|
||||
|
||||
if (!itemsKeyWasSynced) {
|
||||
await this.sessionManager.changeCredentials({
|
||||
currentServerPassword: rootKeys.newRootKey.serverPassword as string,
|
||||
newRootKey: rootKeys.currentRootKey,
|
||||
wrappingKey,
|
||||
})
|
||||
await this.protocolService.reencryptItemsKeys()
|
||||
await rollback()
|
||||
await this.syncService.sync({ awaitAll: true })
|
||||
|
||||
return { error: Error(Messages.CredentialsChangeStrings.Failed) }
|
||||
}
|
||||
}
|
||||
|
||||
return result.response
|
||||
}
|
||||
|
||||
private async recomputeRootKeysForCredentialChange(parameters: {
|
||||
currentPassword: string
|
||||
currentEmail: string
|
||||
origination: KeyParamsOrigination
|
||||
newEmail?: string
|
||||
newPassword?: string
|
||||
}): Promise<{ currentRootKey: SNRootKey; newRootKey: SNRootKey }> {
|
||||
const currentRootKey = await this.protocolService.computeRootKey(
|
||||
parameters.currentPassword,
|
||||
(await this.protocolService.getRootKeyParams()) as SNRootKeyParams,
|
||||
)
|
||||
const newRootKey = await this.protocolService.createRootKey(
|
||||
parameters.newEmail ?? parameters.currentEmail,
|
||||
parameters.newPassword ?? parameters.currentPassword,
|
||||
parameters.origination,
|
||||
)
|
||||
|
||||
return {
|
||||
currentRootKey,
|
||||
newRootKey,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export * from './Application/ApplicationStage'
|
||||
export * from './Application/DeinitCallback'
|
||||
export * from './Application/DeinitSource'
|
||||
export * from './Application/DeinitMode'
|
||||
export * from './Application/UserClientInterface'
|
||||
export * from './User/UserClientInterface'
|
||||
export * from './Application/WebApplicationInterface'
|
||||
export * from './Backups/BackupService'
|
||||
export * from './Challenge'
|
||||
@@ -58,8 +58,13 @@ export * from './Item/ItemRelationshipDirection'
|
||||
export * from './Mutator/MutatorClientInterface'
|
||||
export * from './Payloads/PayloadManagerInterface'
|
||||
export * from './Preferences/PreferenceServiceInterface'
|
||||
export * from './Protection/MobileUnlockTiming'
|
||||
export * from './Protection/ProtectionClientInterface'
|
||||
export * from './Protection/TimingDisplayOption'
|
||||
export * from './Service/AbstractService'
|
||||
export * from './Service/ServiceInterface'
|
||||
export * from './Session/SessionManagerResponse'
|
||||
export * from './Session/SessionsClientInterface'
|
||||
export * from './Status/StatusService'
|
||||
export * from './Status/StatusServiceInterface'
|
||||
export * from './Storage/StorageKeys'
|
||||
@@ -67,6 +72,8 @@ export * from './Storage/InMemoryStore'
|
||||
export * from './Storage/KeyValueStoreInterface'
|
||||
export * from './Storage/StorageServiceInterface'
|
||||
export * from './Storage/StorageTypes'
|
||||
export * from './Strings/InfoStrings'
|
||||
export * from './Strings/Messages'
|
||||
export * from './Subscription/SubscriptionClientInterface'
|
||||
export * from './Subscription/SubscriptionManager'
|
||||
export * from './Sync/SyncMode'
|
||||
@@ -74,5 +81,7 @@ export * from './Sync/SyncOptions'
|
||||
export * from './Sync/SyncQueueStrategy'
|
||||
export * from './Sync/SyncServiceInterface'
|
||||
export * from './Sync/SyncSource'
|
||||
export * from './User/UserClientInterface'
|
||||
export * from './User/UserService'
|
||||
export * from './Workspace/WorkspaceClientInterface'
|
||||
export * from './Workspace/WorkspaceManager'
|
||||
|
||||
Reference in New Issue
Block a user