Files
standardnotes-app-web/packages/snjs/lib/Services/Protection/ProtectionService.ts
Karol Sójko 325737bfbd chore: fix ContentType usage (#2353)
* chore: fix ContentType usage

* chore: fix specs
2023-07-12 13:53:29 +02:00

512 lines
16 KiB
TypeScript

import { ChallengeService } from './../Challenge/ChallengeService'
import { SNLog } from '@Lib/Log'
import {
DecryptedItem,
DecryptedItemInterface,
DecryptedItemMutator,
FileItem,
MutationType,
SNNote,
} from '@standardnotes/models'
import { DiskStorageService } from '@Lib/Services/Storage/DiskStorageService'
import { isNullOrUndefined } from '@standardnotes/utils'
import {
AbstractService,
InternalEventBusInterface,
StorageValueModes,
ApplicationStage,
StorageKey,
Challenge,
ChallengeReason,
ChallengePrompt,
ChallengeValidation,
EncryptionService,
MobileUnlockTiming,
TimingDisplayOption,
ProtectionsClientInterface,
MutatorClientInterface,
} from '@standardnotes/services'
import { ContentType } from '@standardnotes/domain-core'
export enum ProtectionEvent {
UnprotectedSessionBegan = 'UnprotectedSessionBegan',
UnprotectedSessionExpired = 'UnprotectedSessionExpired',
}
export const ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction = 30
export enum UnprotectedAccessSecondsDuration {
OneMinute = 60,
FiveMinutes = 300,
OneHour = 3600,
OneWeek = 604800,
}
export function isValidProtectionSessionLength(number: unknown): boolean {
return typeof number === 'number' && Object.values(UnprotectedAccessSecondsDuration).includes(number)
}
export const ProtectionSessionDurations = [
{
valueInSeconds: UnprotectedAccessSecondsDuration.OneMinute,
label: '1 Minute',
},
{
valueInSeconds: UnprotectedAccessSecondsDuration.FiveMinutes,
label: '5 Minutes',
},
{
valueInSeconds: UnprotectedAccessSecondsDuration.OneHour,
label: '1 Hour',
},
{
valueInSeconds: UnprotectedAccessSecondsDuration.OneWeek,
label: '1 Week',
},
]
/**
* Enforces certain actions to require extra authentication,
* like viewing a protected note, as well as managing how long that
* authentication should be valid for.
*/
export class SNProtectionService extends AbstractService<ProtectionEvent> implements ProtectionsClientInterface {
private sessionExpiryTimeout = -1
private mobilePasscodeTiming: MobileUnlockTiming | undefined = MobileUnlockTiming.OnQuit
private mobileBiometricsTiming: MobileUnlockTiming | undefined = MobileUnlockTiming.OnQuit
constructor(
private encryptionService: EncryptionService,
private mutator: MutatorClientInterface,
private challengeService: ChallengeService,
private storageService: DiskStorageService,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
}
public override deinit(): void {
clearTimeout(this.sessionExpiryTimeout)
;(this.encryptionService as unknown) = undefined
;(this.challengeService as unknown) = undefined
;(this.storageService as unknown) = undefined
super.deinit()
}
override handleApplicationStage(stage: ApplicationStage): Promise<void> {
if (stage === ApplicationStage.LoadedDatabase_12) {
this.updateSessionExpiryTimer(this.getSessionExpiryDate())
this.mobilePasscodeTiming = this.getMobilePasscodeTiming()
this.mobileBiometricsTiming = this.getMobileBiometricsTiming()
}
return Promise.resolve()
}
public hasProtectionSources(): boolean {
return this.encryptionService.hasAccount() || this.encryptionService.hasPasscode() || this.hasBiometricsEnabled()
}
public hasUnprotectedAccessSession(): boolean {
if (!this.hasProtectionSources()) {
return true
}
return this.getSessionExpiryDate() > new Date()
}
public hasBiometricsEnabled(): boolean {
const biometricsState = this.storageService.getValue(StorageKey.BiometricsState, StorageValueModes.Nonwrapped)
return Boolean(biometricsState)
}
public enableBiometrics(): boolean {
if (this.hasBiometricsEnabled()) {
SNLog.onError(Error('Tried to enable biometrics when they already are enabled.'))
return false
}
this.storageService.setValue(StorageKey.BiometricsState, true, StorageValueModes.Nonwrapped)
return true
}
public async disableBiometrics(): Promise<boolean> {
if (!this.hasBiometricsEnabled()) {
SNLog.onError(Error('Tried to disable biometrics when they already are disabled.'))
return false
}
if (await this.validateOrRenewSession(ChallengeReason.DisableBiometrics)) {
this.storageService.setValue(StorageKey.BiometricsState, false, StorageValueModes.Nonwrapped)
return true
} else {
return false
}
}
public createLaunchChallenge(): Challenge | undefined {
const prompts: ChallengePrompt[] = []
if (this.hasBiometricsEnabled()) {
prompts.push(new ChallengePrompt(ChallengeValidation.Biometric))
}
if (this.encryptionService.hasPasscode()) {
prompts.push(new ChallengePrompt(ChallengeValidation.LocalPasscode))
}
if (prompts.length > 0) {
return new Challenge(prompts, ChallengeReason.ApplicationUnlock, false)
} else {
return undefined
}
}
async authorizeProtectedActionForItems<T extends DecryptedItem>(
items: T[],
challengeReason: ChallengeReason,
): Promise<T[]> {
let sessionValidation: Promise<boolean> | undefined
const authorizedItems = []
for (const item of items) {
const needsAuthorization = item.protected && !this.hasUnprotectedAccessSession()
if (needsAuthorization && !sessionValidation) {
sessionValidation = this.validateOrRenewSession(challengeReason)
}
if (!needsAuthorization || (await sessionValidation)) {
authorizedItems.push(item)
}
}
return authorizedItems
}
async authorizeItemAccess(item: DecryptedItem): Promise<boolean> {
if (!item.protected) {
return true
}
return this.authorizeAction(
item.content_type === ContentType.TYPES.Note
? ChallengeReason.AccessProtectedNote
: ChallengeReason.AccessProtectedFile,
{ fallBackToAccountPassword: true, requireAccountPassword: false, forcePrompt: false },
)
}
authorizeAddingPasscode(): Promise<boolean> {
return this.authorizeAction(ChallengeReason.AddPasscode, {
fallBackToAccountPassword: true,
requireAccountPassword: false,
forcePrompt: false,
})
}
authorizeChangingPasscode(): Promise<boolean> {
return this.authorizeAction(ChallengeReason.ChangePasscode, {
fallBackToAccountPassword: true,
requireAccountPassword: false,
forcePrompt: false,
})
}
authorizeRemovingPasscode(): Promise<boolean> {
return this.authorizeAction(ChallengeReason.RemovePasscode, {
fallBackToAccountPassword: true,
requireAccountPassword: false,
forcePrompt: false,
})
}
authorizeSearchingProtectedNotesText(): Promise<boolean> {
return this.authorizeAction(ChallengeReason.SearchProtectedNotesText, {
fallBackToAccountPassword: true,
requireAccountPassword: false,
forcePrompt: false,
})
}
authorizeFileImport(): Promise<boolean> {
return this.authorizeAction(ChallengeReason.ImportFile, {
fallBackToAccountPassword: true,
requireAccountPassword: false,
forcePrompt: false,
})
}
async authorizeBackupCreation(): Promise<boolean> {
return this.authorizeAction(ChallengeReason.ExportBackup, {
fallBackToAccountPassword: true,
requireAccountPassword: false,
forcePrompt: false,
})
}
async authorizeMfaDisable(): Promise<boolean> {
return this.authorizeAction(ChallengeReason.DisableMfa, {
fallBackToAccountPassword: true,
requireAccountPassword: true,
forcePrompt: false,
})
}
async authorizeAutolockIntervalChange(): Promise<boolean> {
return this.authorizeAction(ChallengeReason.ChangeAutolockInterval, {
fallBackToAccountPassword: true,
requireAccountPassword: false,
forcePrompt: false,
})
}
async authorizeSessionRevoking(): Promise<boolean> {
return this.authorizeAction(ChallengeReason.RevokeSession, {
fallBackToAccountPassword: true,
requireAccountPassword: false,
forcePrompt: false,
})
}
async authorizeListedPublishing(): Promise<boolean> {
return this.authorizeAction(ChallengeReason.AuthorizeNoteForListed, {
fallBackToAccountPassword: true,
requireAccountPassword: false,
forcePrompt: true,
})
}
async authorizeAction(
reason: ChallengeReason,
dto: { fallBackToAccountPassword: boolean; requireAccountPassword: boolean; forcePrompt: boolean },
): Promise<boolean> {
return this.validateOrRenewSession(reason, dto)
}
getMobilePasscodeTimingOptions(): TimingDisplayOption[] {
return [
{
title: 'Immediately',
key: MobileUnlockTiming.Immediately,
selected: this.mobilePasscodeTiming === MobileUnlockTiming.Immediately,
},
{
title: 'On Quit',
key: MobileUnlockTiming.OnQuit,
selected: this.mobilePasscodeTiming === MobileUnlockTiming.OnQuit,
},
]
}
getMobileBiometricsTimingOptions(): TimingDisplayOption[] {
return [
{
title: 'Immediately',
key: MobileUnlockTiming.Immediately,
selected: this.mobileBiometricsTiming === MobileUnlockTiming.Immediately,
},
{
title: 'On Quit',
key: MobileUnlockTiming.OnQuit,
selected: this.mobileBiometricsTiming === MobileUnlockTiming.OnQuit,
},
]
}
getMobileBiometricsTiming(): MobileUnlockTiming | undefined {
return this.storageService.getValue<MobileUnlockTiming | undefined>(
StorageKey.MobileBiometricsTiming,
StorageValueModes.Nonwrapped,
MobileUnlockTiming.OnQuit,
)
}
getMobilePasscodeTiming(): MobileUnlockTiming | undefined {
return this.storageService.getValue<MobileUnlockTiming | undefined>(
StorageKey.MobilePasscodeTiming,
StorageValueModes.Nonwrapped,
MobileUnlockTiming.OnQuit,
)
}
setMobileBiometricsTiming(timing: MobileUnlockTiming): void {
this.storageService.setValue(StorageKey.MobileBiometricsTiming, timing, StorageValueModes.Nonwrapped)
this.mobileBiometricsTiming = timing
}
setMobilePasscodeTiming(timing: MobileUnlockTiming): void {
this.storageService.setValue(StorageKey.MobilePasscodeTiming, timing, StorageValueModes.Nonwrapped)
this.mobilePasscodeTiming = timing
}
setMobileScreenshotPrivacyEnabled(isEnabled: boolean) {
return this.storageService.setValue(StorageKey.MobileScreenshotPrivacyEnabled, isEnabled, StorageValueModes.Default)
}
getMobileScreenshotPrivacyEnabled(): boolean {
return this.storageService.getValue(StorageKey.MobileScreenshotPrivacyEnabled, StorageValueModes.Default, false)
}
private async validateOrRenewSession(
reason: ChallengeReason,
{ fallBackToAccountPassword = true, requireAccountPassword = false, forcePrompt = false } = {},
): Promise<boolean> {
if (this.getSessionExpiryDate() > new Date() && !forcePrompt) {
return true
}
const prompts: ChallengePrompt[] = []
if (this.hasBiometricsEnabled()) {
prompts.push(new ChallengePrompt(ChallengeValidation.Biometric))
}
if (this.encryptionService.hasPasscode()) {
prompts.push(new ChallengePrompt(ChallengeValidation.LocalPasscode))
}
if (requireAccountPassword) {
if (!this.encryptionService.hasAccount()) {
throw Error('Requiring account password for challenge with no account')
}
prompts.push(new ChallengePrompt(ChallengeValidation.AccountPassword))
}
if (prompts.length === 0) {
if (fallBackToAccountPassword && this.encryptionService.hasAccount()) {
prompts.push(new ChallengePrompt(ChallengeValidation.AccountPassword))
} else {
return true
}
}
const lastSessionLength = this.getLastSessionLength()
const chosenSessionLength = isValidProtectionSessionLength(lastSessionLength)
? lastSessionLength
: UnprotectedAccessSecondsDuration.OneMinute
prompts.push(
new ChallengePrompt(
ChallengeValidation.ProtectionSessionDuration,
undefined,
undefined,
undefined,
undefined,
chosenSessionLength,
),
)
const response = await this.challengeService.promptForChallengeResponse(new Challenge(prompts, reason, true))
if (response) {
const length = response.values.find(
(value) => value.prompt.validation === ChallengeValidation.ProtectionSessionDuration,
)?.value
if (isNullOrUndefined(length)) {
SNLog.error(Error('No valid protection session length found. Got ' + length))
} else {
this.setSessionLength(length as UnprotectedAccessSecondsDuration)
}
return true
} else {
return false
}
}
public getSessionExpiryDate(): Date {
const expiresAt = this.storageService.getValue<number>(StorageKey.ProtectionExpirey)
if (expiresAt) {
return new Date(expiresAt)
} else {
return new Date()
}
}
public clearSession(): Promise<void> {
void this.setSessionExpiryDate(new Date())
return this.notifyEvent(ProtectionEvent.UnprotectedSessionExpired)
}
private setSessionExpiryDate(date: Date) {
this.storageService.setValue(StorageKey.ProtectionExpirey, date)
}
private getLastSessionLength(): UnprotectedAccessSecondsDuration | undefined {
return this.storageService.getValue(StorageKey.ProtectionSessionLength)
}
private setSessionLength(length: UnprotectedAccessSecondsDuration): void {
this.storageService.setValue(StorageKey.ProtectionSessionLength, length)
const expiresAt = new Date()
expiresAt.setSeconds(expiresAt.getSeconds() + length)
this.setSessionExpiryDate(expiresAt)
this.updateSessionExpiryTimer(expiresAt)
void this.notifyEvent(ProtectionEvent.UnprotectedSessionBegan)
}
private updateSessionExpiryTimer(expiryDate: Date) {
clearTimeout(this.sessionExpiryTimeout)
const timer: TimerHandler = () => {
void this.clearSession()
}
this.sessionExpiryTimeout = setTimeout(timer, expiryDate.getTime() - Date.now())
}
async protectItems<I extends DecryptedItemInterface>(items: I[]): Promise<I[]> {
const protectedItems = await this.mutator.changeItems<DecryptedItemMutator, I>(
items,
(mutator) => {
mutator.protected = true
},
MutationType.NoUpdateUserTimestamps,
)
return protectedItems
}
async unprotectItems<I extends DecryptedItemInterface>(
items: I[],
reason: ChallengeReason,
): Promise<I[] | undefined> {
if (
!(await this.authorizeAction(reason, {
fallBackToAccountPassword: true,
requireAccountPassword: false,
forcePrompt: false,
}))
) {
return undefined
}
const unprotectedItems = await this.mutator.changeItems<DecryptedItemMutator, I>(
items,
(mutator) => {
mutator.protected = false
},
MutationType.NoUpdateUserTimestamps,
)
return unprotectedItems
}
public async protectNote(note: SNNote): Promise<SNNote> {
const result = await this.protectItems([note])
return result[0]
}
public async unprotectNote(note: SNNote): Promise<SNNote | undefined> {
const result = await this.unprotectItems([note], ChallengeReason.UnprotectNote)
return result ? result[0] : undefined
}
public async protectNotes(notes: SNNote[]): Promise<SNNote[]> {
return this.protectItems(notes)
}
public async unprotectNotes(notes: SNNote[]): Promise<SNNote[]> {
const results = await this.unprotectItems(notes, ChallengeReason.UnprotectNote)
return results || []
}
async protectFile(file: FileItem): Promise<FileItem> {
const result = await this.protectItems([file])
return result[0]
}
async unprotectFile(file: FileItem): Promise<FileItem | undefined> {
const result = await this.unprotectItems([file], ChallengeReason.UnprotectFile)
return result ? result[0] : undefined
}
}