feat: add snjs package

This commit is contained in:
Karol Sójko
2022-07-06 14:04:18 +02:00
parent 321a055bae
commit 0e40469e2f
296 changed files with 46109 additions and 187 deletions

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

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

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

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

View File

@@ -0,0 +1,4 @@
export * from './Challenge'
export * from './ChallengeOperation'
export * from './ChallengeResponse'
export * from './ChallengeService'