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:
Karol Sójko
2022-11-02 11:33:02 +01:00
committed by GitHub
parent f687334d7d
commit b2faa815e9
81 changed files with 766 additions and 325 deletions

View File

@@ -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

View 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
}
}

View File

@@ -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
}
>
}

View File

@@ -1,3 +1,4 @@
export * from './Challenge'
export * from './ChallengeInterface'
export * from './ChallengeResponseInterface'
export * from './ChallengeServiceInterface'

View File

@@ -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)[]
}

View File

@@ -0,0 +1,4 @@
export enum MobileUnlockTiming {
Immediately = 'immediately',
OnQuit = 'on-quit',
}

View File

@@ -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>
}

View File

@@ -0,0 +1,7 @@
import { MobileUnlockTiming } from './MobileUnlockTiming'
export type TimingDisplayOption = {
title: string
key: MobileUnlockTiming
selected: boolean
}

View File

@@ -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
}

View File

@@ -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>
}

View File

@@ -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>
}

View 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.",
}

View 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.",
}

View File

@@ -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
}

View File

@@ -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>
}

View 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,
}
}
}

View File

@@ -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'