feat: add snjs package
This commit is contained in:
112
packages/snjs/lib/Services/Challenge/Challenge.ts
Normal file
112
packages/snjs/lib/Services/Challenge/Challenge.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { ChallengeModalTitle, ChallengeStrings } from '../Api/Messages'
|
||||
import { assertUnreachable } from '@standardnotes/utils'
|
||||
import { ChallengeValidation, ChallengeReason, ChallengeInterface, ChallengePrompt } from '@standardnotes/services'
|
||||
|
||||
/**
|
||||
* 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.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
|
||||
}
|
||||
}
|
||||
108
packages/snjs/lib/Services/Challenge/ChallengeOperation.ts
Normal file
108
packages/snjs/lib/Services/Challenge/ChallengeOperation.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Challenge } from './Challenge'
|
||||
import { ChallengeResponse } from './ChallengeResponse'
|
||||
import { removeFromArray } from '@standardnotes/utils'
|
||||
import { ValueCallback } from './ChallengeService'
|
||||
import { ChallengeValue, ChallengeArtifacts } from '@standardnotes/services'
|
||||
|
||||
/**
|
||||
* A challenge operation stores user-submitted values and callbacks.
|
||||
* When its values are updated, it will trigger the associated callbacks (valid/invalid/complete)
|
||||
*/
|
||||
export class ChallengeOperation {
|
||||
private nonvalidatedValues: ChallengeValue[] = []
|
||||
private validValues: ChallengeValue[] = []
|
||||
private invalidValues: ChallengeValue[] = []
|
||||
private artifacts: ChallengeArtifacts = {}
|
||||
|
||||
constructor(
|
||||
public challenge: Challenge,
|
||||
public onValidValue: ValueCallback,
|
||||
public onInvalidValue: ValueCallback,
|
||||
public onNonvalidatedSubmit: (response: ChallengeResponse) => void,
|
||||
public onComplete: (response: ChallengeResponse) => void,
|
||||
public onCancel: () => void,
|
||||
) {}
|
||||
|
||||
deinit() {
|
||||
;(this.challenge as unknown) = undefined
|
||||
;(this.onValidValue as unknown) = undefined
|
||||
;(this.onInvalidValue as unknown) = undefined
|
||||
;(this.onNonvalidatedSubmit as unknown) = undefined
|
||||
;(this.onComplete as unknown) = undefined
|
||||
;(this.onCancel as unknown) = undefined
|
||||
;(this.nonvalidatedValues as unknown) = undefined
|
||||
;(this.validValues as unknown) = undefined
|
||||
;(this.invalidValues as unknown) = undefined
|
||||
;(this.artifacts as unknown) = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this challenge as complete, triggering the resolve function,
|
||||
* as well as notifying the client
|
||||
*/
|
||||
public complete(response?: ChallengeResponse) {
|
||||
if (!response) {
|
||||
response = new ChallengeResponse(this.challenge, this.validValues, this.artifacts)
|
||||
}
|
||||
this.onComplete?.(response)
|
||||
}
|
||||
|
||||
public nonvalidatedSubmit() {
|
||||
const response = new ChallengeResponse(this.challenge, this.nonvalidatedValues.slice(), this.artifacts)
|
||||
this.onNonvalidatedSubmit?.(response)
|
||||
/** Reset values */
|
||||
this.nonvalidatedValues = []
|
||||
}
|
||||
|
||||
public cancel() {
|
||||
this.onCancel?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns Returns true if the challenge has received all valid responses
|
||||
*/
|
||||
public isFinished() {
|
||||
return this.validValues.length === this.challenge.prompts.length
|
||||
}
|
||||
|
||||
private nonvalidatedPrompts() {
|
||||
return this.challenge.prompts.filter((p) => !p.validates)
|
||||
}
|
||||
|
||||
public addNonvalidatedValue(value: ChallengeValue) {
|
||||
const valuesArray = this.nonvalidatedValues
|
||||
const matching = valuesArray.find((v) => v.prompt.id === value.prompt.id)
|
||||
if (matching) {
|
||||
removeFromArray(valuesArray, matching)
|
||||
}
|
||||
valuesArray.push(value)
|
||||
if (this.nonvalidatedValues.length === this.nonvalidatedPrompts().length) {
|
||||
this.nonvalidatedSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the values validation status, as well as handles subsequent actions,
|
||||
* such as completing the operation if all valid values are supplied, as well as
|
||||
* notifying the client of this new value's validation status.
|
||||
*/
|
||||
public setValueStatus(value: ChallengeValue, valid: boolean, artifacts?: ChallengeArtifacts) {
|
||||
const valuesArray = valid ? this.validValues : this.invalidValues
|
||||
const matching = valuesArray.find((v) => v.prompt.validation === value.prompt.validation)
|
||||
if (matching) {
|
||||
removeFromArray(valuesArray, matching)
|
||||
}
|
||||
valuesArray.push(value)
|
||||
Object.assign(this.artifacts, artifacts)
|
||||
|
||||
if (this.isFinished()) {
|
||||
this.complete()
|
||||
} else {
|
||||
if (valid) {
|
||||
this.onValidValue?.(value)
|
||||
} else {
|
||||
this.onInvalidValue?.(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
packages/snjs/lib/Services/Challenge/ChallengeResponse.ts
Normal file
33
packages/snjs/lib/Services/Challenge/ChallengeResponse.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { isNullOrUndefined } from '@standardnotes/utils'
|
||||
import { Challenge } from './Challenge'
|
||||
import {
|
||||
ChallengeResponseInterface,
|
||||
ChallengeValidation,
|
||||
ChallengeValue,
|
||||
ChallengeArtifacts,
|
||||
} from '@standardnotes/services'
|
||||
|
||||
export class ChallengeResponse implements ChallengeResponseInterface {
|
||||
constructor(
|
||||
public readonly challenge: Challenge,
|
||||
public readonly values: ChallengeValue[],
|
||||
public readonly artifacts?: ChallengeArtifacts,
|
||||
) {
|
||||
Object.freeze(this)
|
||||
}
|
||||
|
||||
getValueForType(type: ChallengeValidation): ChallengeValue {
|
||||
const value = this.values.find((value) => value.prompt.validation === type)
|
||||
if (isNullOrUndefined(value)) {
|
||||
throw Error('Could not find value for validation type ' + type)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
getDefaultValue(): ChallengeValue {
|
||||
if (this.values.length > 1) {
|
||||
throw Error('Attempting to retrieve default response value when more than one value exists')
|
||||
}
|
||||
return this.values[0]
|
||||
}
|
||||
}
|
||||
300
packages/snjs/lib/Services/Challenge/ChallengeService.ts
Normal file
300
packages/snjs/lib/Services/Challenge/ChallengeService.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { RootKeyInterface } from '@standardnotes/models'
|
||||
import { EncryptionService } from '@standardnotes/encryption'
|
||||
import { DiskStorageService } from '../Storage/DiskStorageService'
|
||||
import { removeFromArray } from '@standardnotes/utils'
|
||||
import { isValidProtectionSessionLength } from '../Protection/ProtectionService'
|
||||
import {
|
||||
AbstractService,
|
||||
ChallengeServiceInterface,
|
||||
InternalEventBusInterface,
|
||||
ChallengeArtifacts,
|
||||
ChallengeReason,
|
||||
ChallengeValidation,
|
||||
ChallengeValue,
|
||||
ChallengeInterface,
|
||||
ChallengePromptInterface,
|
||||
ChallengePrompt,
|
||||
} from '@standardnotes/services'
|
||||
import { ChallengeResponse } from './ChallengeResponse'
|
||||
import { ChallengeOperation } from './ChallengeOperation'
|
||||
import { Challenge } from './Challenge'
|
||||
|
||||
type ChallengeValidationResponse = {
|
||||
valid: boolean
|
||||
artifacts?: ChallengeArtifacts
|
||||
}
|
||||
|
||||
export type ValueCallback = (value: ChallengeValue) => void
|
||||
|
||||
export type ChallengeObserver = {
|
||||
onValidValue?: ValueCallback
|
||||
onInvalidValue?: ValueCallback
|
||||
onNonvalidatedSubmit?: (response: ChallengeResponse) => void
|
||||
onComplete?: (response: ChallengeResponse) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
const clearChallengeObserver = (observer: ChallengeObserver) => {
|
||||
observer.onCancel = undefined
|
||||
observer.onComplete = undefined
|
||||
observer.onValidValue = undefined
|
||||
observer.onInvalidValue = undefined
|
||||
observer.onNonvalidatedSubmit = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The challenge service creates, updates and keeps track of running challenge operations.
|
||||
*/
|
||||
export class ChallengeService extends AbstractService implements ChallengeServiceInterface {
|
||||
private challengeOperations: Record<string, ChallengeOperation> = {}
|
||||
public sendChallenge!: (challenge: Challenge) => void
|
||||
private challengeObservers: Record<string, ChallengeObserver[]> = {}
|
||||
|
||||
constructor(
|
||||
private storageService: DiskStorageService,
|
||||
private protocolService: EncryptionService,
|
||||
protected override internalEventBus: InternalEventBusInterface,
|
||||
) {
|
||||
super(internalEventBus)
|
||||
}
|
||||
|
||||
public override deinit() {
|
||||
;(this.storageService as unknown) = undefined
|
||||
;(this.protocolService as unknown) = undefined
|
||||
;(this.sendChallenge as unknown) = undefined
|
||||
;(this.challengeOperations as unknown) = undefined
|
||||
;(this.challengeObservers as unknown) = undefined
|
||||
super.deinit()
|
||||
}
|
||||
|
||||
public promptForChallengeResponse(challenge: Challenge): Promise<ChallengeResponse | undefined> {
|
||||
return new Promise<ChallengeResponse | undefined>((resolve) => {
|
||||
this.createOrGetChallengeOperation(challenge, resolve)
|
||||
this.sendChallenge(challenge)
|
||||
})
|
||||
}
|
||||
|
||||
public createChallenge(
|
||||
prompts: ChallengePromptInterface[],
|
||||
reason: ChallengeReason,
|
||||
cancelable: boolean,
|
||||
heading?: string,
|
||||
subheading?: string,
|
||||
): ChallengeInterface {
|
||||
return new Challenge(prompts, reason, cancelable, heading, subheading)
|
||||
}
|
||||
|
||||
public async validateChallengeValue(value: ChallengeValue): Promise<ChallengeValidationResponse> {
|
||||
switch (value.prompt.validation) {
|
||||
case ChallengeValidation.LocalPasscode:
|
||||
return this.protocolService.validatePasscode(value.value as string)
|
||||
case ChallengeValidation.AccountPassword:
|
||||
return this.protocolService.validateAccountPassword(value.value as string)
|
||||
case ChallengeValidation.Biometric:
|
||||
return { valid: value.value === true }
|
||||
case ChallengeValidation.ProtectionSessionDuration:
|
||||
return { valid: isValidProtectionSessionLength(value.value) }
|
||||
default:
|
||||
throw Error(`Unhandled validation mode ${value.prompt.validation}`)
|
||||
}
|
||||
}
|
||||
|
||||
public async promptForCorrectPasscode(reason: ChallengeReason): Promise<string | undefined> {
|
||||
const challenge = new Challenge([new ChallengePrompt(ChallengeValidation.LocalPasscode)], reason, true)
|
||||
const response = await this.promptForChallengeResponse(challenge)
|
||||
if (!response) {
|
||||
return undefined
|
||||
}
|
||||
const value = response.getValueForType(ChallengeValidation.LocalPasscode)
|
||||
return value.value as string
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the wrapping key for operations that require resaving the root key
|
||||
* (changing the account password, signing in, registering, or upgrading protocol)
|
||||
* Returns empty object if no passcode is configured.
|
||||
* Otherwise returns {cancled: true} if the operation is canceled, or
|
||||
* {wrappingKey} with the result.
|
||||
* @param passcode - If the consumer already has access to the passcode,
|
||||
* they can pass it here so that the user is not prompted again.
|
||||
*/
|
||||
async getWrappingKeyIfApplicable(passcode?: string): Promise<
|
||||
| {
|
||||
canceled?: undefined
|
||||
wrappingKey?: undefined
|
||||
}
|
||||
| {
|
||||
canceled: boolean
|
||||
wrappingKey?: undefined
|
||||
}
|
||||
| {
|
||||
wrappingKey: RootKeyInterface
|
||||
canceled?: undefined
|
||||
}
|
||||
> {
|
||||
if (!this.protocolService.hasPasscode()) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (!passcode) {
|
||||
passcode = await this.promptForCorrectPasscode(ChallengeReason.ResaveRootKey)
|
||||
if (!passcode) {
|
||||
return { canceled: true }
|
||||
}
|
||||
}
|
||||
|
||||
const wrappingKey = await this.protocolService.computeWrappingKey(passcode)
|
||||
return { wrappingKey }
|
||||
}
|
||||
|
||||
public isPasscodeLocked() {
|
||||
return this.protocolService.isPasscodeLocked()
|
||||
}
|
||||
|
||||
public addChallengeObserver(challenge: Challenge, observer: ChallengeObserver) {
|
||||
const observers = this.challengeObservers[challenge.id] || []
|
||||
|
||||
observers.push(observer)
|
||||
|
||||
this.challengeObservers[challenge.id] = observers
|
||||
|
||||
return () => {
|
||||
clearChallengeObserver(observer)
|
||||
|
||||
removeFromArray(observers, observer)
|
||||
}
|
||||
}
|
||||
|
||||
private createOrGetChallengeOperation(
|
||||
challenge: Challenge,
|
||||
resolve: (response: ChallengeResponse | undefined) => void,
|
||||
): ChallengeOperation {
|
||||
let operation = this.getChallengeOperation(challenge)
|
||||
|
||||
if (!operation) {
|
||||
operation = new ChallengeOperation(
|
||||
challenge,
|
||||
(value: ChallengeValue) => {
|
||||
this.onChallengeValidValue(challenge, value)
|
||||
},
|
||||
(value: ChallengeValue) => {
|
||||
this.onChallengeInvalidValue(challenge, value)
|
||||
},
|
||||
(response: ChallengeResponse) => {
|
||||
this.onChallengeNonvalidatedSubmit(challenge, response)
|
||||
resolve(response)
|
||||
},
|
||||
(response: ChallengeResponse) => {
|
||||
this.onChallengeComplete(challenge, response)
|
||||
resolve(response)
|
||||
},
|
||||
() => {
|
||||
this.onChallengeCancel(challenge)
|
||||
resolve(undefined)
|
||||
},
|
||||
)
|
||||
|
||||
this.challengeOperations[challenge.id] = operation
|
||||
}
|
||||
return operation
|
||||
}
|
||||
|
||||
private performOnObservers(challenge: Challenge, perform: (observer: ChallengeObserver) => void) {
|
||||
const observers = this.challengeObservers[challenge.id] || []
|
||||
|
||||
for (const observer of observers) {
|
||||
perform(observer)
|
||||
}
|
||||
}
|
||||
|
||||
private onChallengeValidValue(challenge: Challenge, value: ChallengeValue) {
|
||||
this.performOnObservers(challenge, (observer) => {
|
||||
observer.onValidValue?.(value)
|
||||
})
|
||||
}
|
||||
|
||||
private onChallengeInvalidValue(challenge: Challenge, value: ChallengeValue) {
|
||||
this.performOnObservers(challenge, (observer) => {
|
||||
observer.onInvalidValue?.(value)
|
||||
})
|
||||
}
|
||||
|
||||
private onChallengeNonvalidatedSubmit(challenge: Challenge, response: ChallengeResponse) {
|
||||
this.performOnObservers(challenge, (observer) => {
|
||||
observer.onNonvalidatedSubmit?.(response)
|
||||
})
|
||||
}
|
||||
|
||||
private onChallengeComplete(challenge: Challenge, response: ChallengeResponse) {
|
||||
this.performOnObservers(challenge, (observer) => {
|
||||
observer.onComplete?.(response)
|
||||
})
|
||||
}
|
||||
|
||||
private onChallengeCancel(challenge: Challenge) {
|
||||
this.performOnObservers(challenge, (observer) => {
|
||||
observer.onCancel?.()
|
||||
})
|
||||
}
|
||||
|
||||
private getChallengeOperation(challenge: Challenge) {
|
||||
return this.challengeOperations[challenge.id]
|
||||
}
|
||||
|
||||
private deleteChallengeOperation(operation: ChallengeOperation) {
|
||||
const challenge = operation.challenge
|
||||
operation.deinit()
|
||||
|
||||
delete this.challengeOperations[challenge.id]
|
||||
}
|
||||
|
||||
public cancelChallenge(challenge: Challenge) {
|
||||
const operation = this.challengeOperations[challenge.id]
|
||||
operation.cancel()
|
||||
|
||||
this.deleteChallengeOperation(operation)
|
||||
}
|
||||
|
||||
public completeChallenge(challenge: Challenge): void {
|
||||
const operation = this.challengeOperations[challenge.id]
|
||||
operation.complete()
|
||||
|
||||
this.deleteChallengeOperation(operation)
|
||||
}
|
||||
|
||||
public async submitValuesForChallenge(challenge: Challenge, values: ChallengeValue[]) {
|
||||
if (values.length === 0) {
|
||||
throw Error('Attempting to submit 0 values for challenge')
|
||||
}
|
||||
|
||||
for (const value of values) {
|
||||
if (!value.prompt.validates) {
|
||||
const operation = this.getChallengeOperation(challenge)
|
||||
operation.addNonvalidatedValue(value)
|
||||
} else {
|
||||
const { valid, artifacts } = await this.validateChallengeValue(value)
|
||||
this.setValidationStatusForChallenge(challenge, value, valid, artifacts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public setValidationStatusForChallenge(
|
||||
challenge: Challenge,
|
||||
value: ChallengeValue,
|
||||
valid: boolean,
|
||||
artifacts?: ChallengeArtifacts,
|
||||
) {
|
||||
const operation = this.getChallengeOperation(challenge)
|
||||
operation.setValueStatus(value, valid, artifacts)
|
||||
|
||||
if (operation.isFinished()) {
|
||||
this.deleteChallengeOperation(operation)
|
||||
|
||||
const observers = this.challengeObservers[challenge.id]
|
||||
observers.forEach(clearChallengeObserver)
|
||||
observers.length = 0
|
||||
|
||||
delete this.challengeObservers[challenge.id]
|
||||
}
|
||||
}
|
||||
}
|
||||
4
packages/snjs/lib/Services/Challenge/index.ts
Normal file
4
packages/snjs/lib/Services/Challenge/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './Challenge'
|
||||
export * from './ChallengeOperation'
|
||||
export * from './ChallengeResponse'
|
||||
export * from './ChallengeService'
|
||||
Reference in New Issue
Block a user