internal: incomplete vault systems behind feature flag (#2340)

This commit is contained in:
Mo
2023-06-30 09:01:56 -05:00
committed by GitHub
parent d16e401bb9
commit b032eb9c9b
638 changed files with 20321 additions and 4813 deletions

View File

@@ -18,8 +18,18 @@ export abstract class AlertService {
cancelButtonText?: string,
): Promise<boolean>
abstract confirmV2(dto: {
text: string
title?: string
confirmButtonText?: string
confirmButtonType?: ButtonType
cancelButtonText?: string
}): Promise<boolean>
abstract alert(text: string, title?: string, closeButtonText?: string): Promise<void>
abstract alertV2(dto: { text: string; title?: string; closeButtonText?: string }): Promise<void>
abstract blockingDialog(text: string, title?: string): DismissBlockingDialog | Promise<DismissBlockingDialog>
showErrorAlert(error: ClientDisplayableError): Promise<void> {

View File

@@ -1,5 +1,18 @@
import { SyncOptions } from './../Sync/SyncOptions'
import { ImportDataReturnType } from './../Mutator/ImportDataUseCase'
import { ChallengeServiceInterface } from './../Challenge/ChallengeServiceInterface'
import { VaultServiceInterface } from './../Vaults/VaultServiceInterface'
import { ApplicationIdentifier, ContentType } from '@standardnotes/common'
import { BackupFile, DecryptedItemInterface, ItemStream, Platform, PrefKey, PrefValue } from '@standardnotes/models'
import {
BackupFile,
DecryptedItemInterface,
DecryptedItemMutator,
ItemStream,
PayloadEmitSource,
Platform,
PrefKey,
PrefValue,
} from '@standardnotes/models'
import { BackupServiceInterface, FilesClientInterface } from '@standardnotes/files'
import { AlertService } from '../Alert/AlertService'
@@ -9,7 +22,7 @@ import { ApplicationEventCallback } from '../Event/ApplicationEventCallback'
import { FeaturesClientInterface } from '../Feature/FeaturesClientInterface'
import { SubscriptionClientInterface } from '../Subscription/SubscriptionClientInterface'
import { DeviceInterface } from '../Device/DeviceInterface'
import { ItemsClientInterface } from '../Item/ItemsClientInterface'
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
import { MutatorClientInterface } from '../Mutator/MutatorClientInterface'
import { StorageValueModes } from '../Storage/StorageTypes'
@@ -24,6 +37,7 @@ export interface ApplicationInterface {
isStarted(): boolean
isLaunched(): boolean
addEventObserver(callback: ApplicationEventCallback, singleEvent?: ApplicationEvent): () => void
addSingleEventObserver(event: ApplicationEvent, callback: ApplicationEventCallback): () => void
hasProtectionSources(): boolean
createEncryptedBackupFileForAutomatedDesktopBackups(): Promise<BackupFile | undefined>
createEncryptedBackupFile(): Promise<BackupFile | undefined>
@@ -32,7 +46,7 @@ export interface ApplicationInterface {
lock(): Promise<void>
softLockBiometrics(): void
setValue(key: string, value: unknown, mode?: StorageValueModes): void
getValue(key: string, mode?: StorageValueModes): unknown
getValue<T>(key: string, mode?: StorageValueModes): T
removeValue(key: string, mode?: StorageValueModes): Promise<void>
isLocked(): Promise<boolean>
getPreference<K extends PrefKey>(key: K): PrefValue[K] | undefined
@@ -44,15 +58,42 @@ export interface ApplicationInterface {
stream: ItemStream<I>,
): () => void
hasAccount(): boolean
importData(data: BackupFile, awaitSync?: boolean): Promise<ImportDataReturnType>
/**
* Mutates a pre-existing item, marks it as dirty, and syncs it
*/
changeAndSaveItem<M extends DecryptedItemMutator = DecryptedItemMutator>(
itemToLookupUuidFor: DecryptedItemInterface,
mutate: (mutator: M) => void,
updateTimestamps?: boolean,
emitSource?: PayloadEmitSource,
syncOptions?: SyncOptions,
): Promise<DecryptedItemInterface | undefined>
/**
* Mutates pre-existing items, marks them as dirty, and syncs
*/
changeAndSaveItems<M extends DecryptedItemMutator = DecryptedItemMutator>(
itemsToLookupUuidsFor: DecryptedItemInterface[],
mutate: (mutator: M) => void,
updateTimestamps?: boolean,
emitSource?: PayloadEmitSource,
syncOptions?: SyncOptions,
): Promise<void>
get features(): FeaturesClientInterface
get componentManager(): ComponentManagerInterface
get items(): ItemsClientInterface
get items(): ItemManagerInterface
get mutator(): MutatorClientInterface
get user(): UserClientInterface
get files(): FilesClientInterface
get subscriptions(): SubscriptionClientInterface
get fileBackups(): BackupServiceInterface | undefined
get sessions(): SessionsClientInterface
get vaults(): VaultServiceInterface
get challenges(): ChallengeServiceInterface
get alerts(): AlertService
readonly identifier: ApplicationIdentifier
readonly platform: Platform
deviceInterface: DeviceInterface

View File

@@ -1,22 +0,0 @@
import { MobileDeviceInterface } from './../Device/MobileDeviceInterface'
import { DesktopManagerInterface } from '../Device/DesktopManagerInterface'
import { WebAppEvent } from '../Event/WebAppEvent'
import { ApplicationInterface } from './ApplicationInterface'
export interface WebApplicationInterface extends ApplicationInterface {
notifyWebEvent(event: WebAppEvent, data?: unknown): void
getDesktopService(): DesktopManagerInterface | undefined
handleMobileEnteringBackgroundEvent(): Promise<void>
handleMobileGainingFocusEvent(): Promise<void>
handleMobileLosingFocusEvent(): Promise<void>
handleMobileResumingFromBackgroundEvent(): Promise<void>
handleMobileColorSchemeChangeEvent(): void
handleMobileKeyboardWillChangeFrameEvent(frame: { height: number; contentHeight: number }): void
handleMobileKeyboardDidChangeFrameEvent(frame: { height: number; contentHeight: number }): void
isNativeMobileWeb(): boolean
mobileDevice(): MobileDeviceInterface
handleAndroidBackButtonPressed(): void
addAndroidBackHandlerEventListener(listener: () => boolean): (() => void) | undefined
setAndroidBackHandlerFallbackListener(listener: () => boolean): void
generateUUID(): string
}

View File

@@ -0,0 +1,63 @@
import { MutatorClientInterface } from './../Mutator/MutatorClientInterface'
import { HttpServiceInterface } from '@standardnotes/api'
import { AsymmetricMessageService } from './AsymmetricMessageService'
import { ContactServiceInterface } from './../Contacts/ContactServiceInterface'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
import { SyncServiceInterface } from '../Sync/SyncServiceInterface'
import { AsymmetricMessageServerHash } from '@standardnotes/responses'
import { AsymmetricMessagePayloadType } from '@standardnotes/models'
describe('AsymmetricMessageService', () => {
let service: AsymmetricMessageService
beforeEach(() => {
const http = {} as jest.Mocked<HttpServiceInterface>
http.delete = jest.fn()
const encryption = {} as jest.Mocked<EncryptionProviderInterface>
const contacts = {} as jest.Mocked<ContactServiceInterface>
const items = {} as jest.Mocked<ItemManagerInterface>
const sync = {} as jest.Mocked<SyncServiceInterface>
const mutator = {} as jest.Mocked<MutatorClientInterface>
const eventBus = {} as jest.Mocked<InternalEventBusInterface>
eventBus.addEventHandler = jest.fn()
service = new AsymmetricMessageService(http, encryption, contacts, items, mutator, sync, eventBus)
})
it('should process incoming messages oldest first', async () => {
const messages: AsymmetricMessageServerHash[] = [
{
uuid: 'newer-message',
user_uuid: '1',
sender_uuid: '2',
encrypted_message: 'encrypted_message',
created_at_timestamp: 2,
updated_at_timestamp: 2,
},
{
uuid: 'older-message',
user_uuid: '1',
sender_uuid: '2',
encrypted_message: 'encrypted_message',
created_at_timestamp: 1,
updated_at_timestamp: 1,
},
]
const trustedPayloadMock = { type: AsymmetricMessagePayloadType.ContactShare, data: { recipientUuid: '1' } }
service.getTrustedMessagePayload = jest.fn().mockReturnValue(trustedPayloadMock)
const handleTrustedContactShareMessageMock = jest.fn()
service.handleTrustedContactShareMessage = handleTrustedContactShareMessageMock
await service.handleRemoteReceivedAsymmetricMessages(messages)
expect(handleTrustedContactShareMessageMock.mock.calls[0][0]).toEqual(messages[1])
expect(handleTrustedContactShareMessageMock.mock.calls[1][0]).toEqual(messages[0])
})
})

View File

@@ -0,0 +1,187 @@
import { MutatorClientInterface } from './../Mutator/MutatorClientInterface'
import { ContactServiceInterface } from './../Contacts/ContactServiceInterface'
import { AsymmetricMessageServerHash, ClientDisplayableError } from '@standardnotes/responses'
import { SyncEvent, SyncEventReceivedAsymmetricMessagesData } from '../Event/SyncEvent'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { InternalEventHandlerInterface } from '../Internal/InternalEventHandlerInterface'
import { InternalEventInterface } from '../Internal/InternalEventInterface'
import { AbstractService } from '../Service/AbstractService'
import { GetAsymmetricMessageTrustedPayload } from './UseCase/GetAsymmetricMessageTrustedPayload'
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import {
AsymmetricMessageSharedVaultRootKeyChanged,
AsymmetricMessagePayloadType,
AsymmetricMessageSenderKeypairChanged,
AsymmetricMessageTrustedContactShare,
AsymmetricMessagePayload,
AsymmetricMessageSharedVaultMetadataChanged,
VaultListingMutator,
} from '@standardnotes/models'
import { HandleTrustedSharedVaultRootKeyChangedMessage } from './UseCase/HandleTrustedSharedVaultRootKeyChangedMessage'
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
import { SyncServiceInterface } from '../Sync/SyncServiceInterface'
import { SessionEvent } from '../Session/SessionEvent'
import { AsymmetricMessageServer, HttpServiceInterface } from '@standardnotes/api'
import { UserKeyPairChangedEventData } from '../Session/UserKeyPairChangedEventData'
import { SendOwnContactChangeMessage } from './UseCase/SendOwnContactChangeMessage'
import { GetOutboundAsymmetricMessages } from './UseCase/GetOutboundAsymmetricMessages'
import { GetInboundAsymmetricMessages } from './UseCase/GetInboundAsymmetricMessages'
import { GetVaultUseCase } from '../Vaults/UseCase/GetVault'
export class AsymmetricMessageService extends AbstractService implements InternalEventHandlerInterface {
private messageServer: AsymmetricMessageServer
constructor(
http: HttpServiceInterface,
private encryption: EncryptionProviderInterface,
private contacts: ContactServiceInterface,
private items: ItemManagerInterface,
private mutator: MutatorClientInterface,
private sync: SyncServiceInterface,
eventBus: InternalEventBusInterface,
) {
super(eventBus)
this.messageServer = new AsymmetricMessageServer(http)
eventBus.addEventHandler(this, SyncEvent.ReceivedAsymmetricMessages)
eventBus.addEventHandler(this, SessionEvent.UserKeyPairChanged)
}
async handleEvent(event: InternalEventInterface): Promise<void> {
if (event.type === SessionEvent.UserKeyPairChanged) {
void this.messageServer.deleteAllInboundMessages()
void this.sendOwnContactChangeEventToAllContacts(event.payload as UserKeyPairChangedEventData)
}
if (event.type === SyncEvent.ReceivedAsymmetricMessages) {
void this.handleRemoteReceivedAsymmetricMessages(event.payload as SyncEventReceivedAsymmetricMessagesData)
}
}
public async getOutboundMessages(): Promise<AsymmetricMessageServerHash[] | ClientDisplayableError> {
const usecase = new GetOutboundAsymmetricMessages(this.messageServer)
return usecase.execute()
}
public async getInboundMessages(): Promise<AsymmetricMessageServerHash[] | ClientDisplayableError> {
const usecase = new GetInboundAsymmetricMessages(this.messageServer)
return usecase.execute()
}
async sendOwnContactChangeEventToAllContacts(data: UserKeyPairChangedEventData): Promise<void> {
if (!data.oldKeyPair || !data.oldSigningKeyPair) {
return
}
const useCase = new SendOwnContactChangeMessage(this.encryption, this.messageServer)
const contacts = this.contacts.getAllContacts()
for (const contact of contacts) {
if (contact.isMe) {
continue
}
await useCase.execute({
senderOldKeyPair: data.oldKeyPair,
senderOldSigningKeyPair: data.oldSigningKeyPair,
senderNewKeyPair: data.newKeyPair,
senderNewSigningKeyPair: data.newSigningKeyPair,
contact,
})
}
}
async handleRemoteReceivedAsymmetricMessages(messages: AsymmetricMessageServerHash[]): Promise<void> {
if (messages.length === 0) {
return
}
const sortedMessages = messages.slice().sort((a, b) => a.created_at_timestamp - b.created_at_timestamp)
for (const message of sortedMessages) {
const trustedMessagePayload = this.getTrustedMessagePayload(message)
if (!trustedMessagePayload) {
continue
}
if (trustedMessagePayload.data.recipientUuid !== message.user_uuid) {
continue
}
if (trustedMessagePayload.type === AsymmetricMessagePayloadType.ContactShare) {
await this.handleTrustedContactShareMessage(message, trustedMessagePayload)
} else if (trustedMessagePayload.type === AsymmetricMessagePayloadType.SenderKeypairChanged) {
await this.handleTrustedSenderKeypairChangedMessage(message, trustedMessagePayload)
} else if (trustedMessagePayload.type === AsymmetricMessagePayloadType.SharedVaultRootKeyChanged) {
await this.handleTrustedSharedVaultRootKeyChangedMessage(message, trustedMessagePayload)
} else if (trustedMessagePayload.type === AsymmetricMessagePayloadType.SharedVaultMetadataChanged) {
await this.handleVaultMetadataChangedMessage(message, trustedMessagePayload)
} else if (trustedMessagePayload.type === AsymmetricMessagePayloadType.SharedVaultInvite) {
throw new Error('Shared vault invites payloads are not handled as part of asymmetric messages')
}
await this.deleteMessageAfterProcessing(message)
}
}
getTrustedMessagePayload(message: AsymmetricMessageServerHash): AsymmetricMessagePayload | undefined {
const useCase = new GetAsymmetricMessageTrustedPayload(this.encryption, this.contacts)
return useCase.execute({
privateKey: this.encryption.getKeyPair().privateKey,
message,
})
}
private async deleteMessageAfterProcessing(message: AsymmetricMessageServerHash): Promise<void> {
await this.messageServer.deleteMessage({ messageUuid: message.uuid })
}
async handleVaultMetadataChangedMessage(
_message: AsymmetricMessageServerHash,
trustedPayload: AsymmetricMessageSharedVaultMetadataChanged,
): Promise<void> {
const vault = new GetVaultUseCase(this.items).execute({ sharedVaultUuid: trustedPayload.data.sharedVaultUuid })
if (!vault) {
return
}
await this.mutator.changeItem<VaultListingMutator>(vault, (mutator) => {
mutator.name = trustedPayload.data.name
mutator.description = trustedPayload.data.description
})
}
async handleTrustedContactShareMessage(
_message: AsymmetricMessageServerHash,
trustedPayload: AsymmetricMessageTrustedContactShare,
): Promise<void> {
await this.contacts.createOrUpdateTrustedContactFromContactShare(trustedPayload.data.trustedContact)
}
private async handleTrustedSenderKeypairChangedMessage(
message: AsymmetricMessageServerHash,
trustedPayload: AsymmetricMessageSenderKeypairChanged,
): Promise<void> {
await this.contacts.createOrEditTrustedContact({
contactUuid: message.sender_uuid,
publicKey: trustedPayload.data.newEncryptionPublicKey,
signingPublicKey: trustedPayload.data.newSigningPublicKey,
})
}
private async handleTrustedSharedVaultRootKeyChangedMessage(
_message: AsymmetricMessageServerHash,
trustedPayload: AsymmetricMessageSharedVaultRootKeyChanged,
): Promise<void> {
const useCase = new HandleTrustedSharedVaultRootKeyChangedMessage(
this.mutator,
this.items,
this.sync,
this.encryption,
)
await useCase.execute(trustedPayload)
}
}

View File

@@ -0,0 +1,23 @@
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import { ContactServiceInterface } from '../../Contacts/ContactServiceInterface'
import { AsymmetricMessageServerHash } from '@standardnotes/responses'
import { AsymmetricMessagePayload } from '@standardnotes/models'
export class GetAsymmetricMessageTrustedPayload<M extends AsymmetricMessagePayload> {
constructor(private encryption: EncryptionProviderInterface, private contacts: ContactServiceInterface) {}
execute(dto: { privateKey: string; message: AsymmetricMessageServerHash }): M | undefined {
const trustedContact = this.contacts.findTrustedContact(dto.message.sender_uuid)
if (!trustedContact) {
return undefined
}
const decryptionResult = this.encryption.asymmetricallyDecryptMessage<M>({
encryptedString: dto.message.encrypted_message,
trustedSender: trustedContact,
privateKey: dto.privateKey,
})
return decryptionResult
}
}

View File

@@ -0,0 +1,17 @@
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import { AsymmetricMessageServerHash } from '@standardnotes/responses'
import { AsymmetricMessagePayload } from '@standardnotes/models'
export class GetAsymmetricMessageUntrustedPayload<M extends AsymmetricMessagePayload> {
constructor(private encryption: EncryptionProviderInterface) {}
execute(dto: { privateKey: string; message: AsymmetricMessageServerHash }): M | undefined {
const decryptionResult = this.encryption.asymmetricallyDecryptMessage<M>({
encryptedString: dto.message.encrypted_message,
trustedSender: undefined,
privateKey: dto.privateKey,
})
return decryptionResult
}
}

View File

@@ -0,0 +1,16 @@
import { ClientDisplayableError, isErrorResponse, AsymmetricMessageServerHash } from '@standardnotes/responses'
import { AsymmetricMessageServerInterface } from '@standardnotes/api'
export class GetInboundAsymmetricMessages {
constructor(private messageServer: AsymmetricMessageServerInterface) {}
async execute(): Promise<AsymmetricMessageServerHash[] | ClientDisplayableError> {
const response = await this.messageServer.getMessages()
if (isErrorResponse(response)) {
return ClientDisplayableError.FromError(response.data.error)
}
return response.data.messages
}
}

View File

@@ -0,0 +1,16 @@
import { ClientDisplayableError, isErrorResponse, AsymmetricMessageServerHash } from '@standardnotes/responses'
import { AsymmetricMessageServerInterface } from '@standardnotes/api'
export class GetOutboundAsymmetricMessages {
constructor(private messageServer: AsymmetricMessageServerInterface) {}
async execute(): Promise<AsymmetricMessageServerHash[] | ClientDisplayableError> {
const response = await this.messageServer.getOutboundUserMessages()
if (isErrorResponse(response)) {
return ClientDisplayableError.FromError(response.data.error)
}
return response.data.messages
}
}

View File

@@ -0,0 +1,67 @@
import { MutatorClientInterface } from './../../Mutator/MutatorClientInterface'
import { HandleTrustedSharedVaultInviteMessage } from './HandleTrustedSharedVaultInviteMessage'
import { SyncServiceInterface } from '../../Sync/SyncServiceInterface'
import { ContactServiceInterface } from '../../Contacts/ContactServiceInterface'
import { ContentType } from '@standardnotes/common'
import {
AsymmetricMessagePayloadType,
AsymmetricMessageSharedVaultInvite,
KeySystemRootKeyContent,
} from '@standardnotes/models'
describe('HandleTrustedSharedVaultInviteMessage', () => {
let mutatorMock: jest.Mocked<MutatorClientInterface>
let syncServiceMock: jest.Mocked<SyncServiceInterface>
let contactServiceMock: jest.Mocked<ContactServiceInterface>
beforeEach(() => {
mutatorMock = {
createItem: jest.fn(),
} as any
syncServiceMock = {
sync: jest.fn(),
} as any
contactServiceMock = {
createOrEditTrustedContact: jest.fn(),
} as any
})
it('should create root key before creating vault listing so that propagated vault listings do not appear as locked', async () => {
const handleTrustedSharedVaultInviteMessage = new HandleTrustedSharedVaultInviteMessage(
mutatorMock,
syncServiceMock,
contactServiceMock,
)
const testMessage = {
type: AsymmetricMessagePayloadType.SharedVaultInvite,
data: {
recipientUuid: 'test-recipient-uuid',
rootKey: {
systemIdentifier: 'test-system-identifier',
} as jest.Mocked<KeySystemRootKeyContent>,
metadata: {
name: 'test-name',
},
trustedContacts: [],
},
} as jest.Mocked<AsymmetricMessageSharedVaultInvite>
const sharedVaultUuid = 'test-shared-vault-uuid'
const senderUuid = 'test-sender-uuid'
await handleTrustedSharedVaultInviteMessage.execute(testMessage, sharedVaultUuid, senderUuid)
const keySystemRootKeyCallIndex = mutatorMock.createItem.mock.calls.findIndex(
([contentType]) => contentType === ContentType.KeySystemRootKey,
)
const vaultListingCallIndex = mutatorMock.createItem.mock.calls.findIndex(
([contentType]) => contentType === ContentType.VaultListing,
)
expect(keySystemRootKeyCallIndex).toBeLessThan(vaultListingCallIndex)
})
})

View File

@@ -0,0 +1,64 @@
import { ContactServiceInterface } from './../../Contacts/ContactServiceInterface'
import { SyncServiceInterface } from '../../Sync/SyncServiceInterface'
import {
KeySystemRootKeyInterface,
AsymmetricMessageSharedVaultInvite,
KeySystemRootKeyContent,
FillItemContent,
FillItemContentSpecialized,
VaultListingContentSpecialized,
KeySystemRootKeyStorageMode,
} from '@standardnotes/models'
import { ContentType } from '@standardnotes/common'
import { MutatorClientInterface } from '../../Mutator/MutatorClientInterface'
export class HandleTrustedSharedVaultInviteMessage {
constructor(
private mutator: MutatorClientInterface,
private sync: SyncServiceInterface,
private contacts: ContactServiceInterface,
) {}
async execute(
message: AsymmetricMessageSharedVaultInvite,
sharedVaultUuid: string,
senderUuid: string,
): Promise<void> {
const { rootKey: rootKeyContent, trustedContacts, metadata } = message.data
const content: VaultListingContentSpecialized = {
systemIdentifier: rootKeyContent.systemIdentifier,
rootKeyParams: rootKeyContent.keyParams,
keyStorageMode: KeySystemRootKeyStorageMode.Synced,
name: metadata.name,
description: metadata.description,
sharing: {
sharedVaultUuid: sharedVaultUuid,
ownerUserUuid: senderUuid,
},
}
await this.mutator.createItem<KeySystemRootKeyInterface>(
ContentType.KeySystemRootKey,
FillItemContent<KeySystemRootKeyContent>(rootKeyContent),
true,
)
await this.mutator.createItem(ContentType.VaultListing, FillItemContentSpecialized(content), true)
for (const contact of trustedContacts) {
if (contact.isMe) {
throw new Error('Should not receive isMe contact from invite')
}
await this.contacts.createOrEditTrustedContact({
name: contact.name,
contactUuid: contact.contactUuid,
publicKey: contact.publicKeySet.encryption,
signingPublicKey: contact.publicKeySet.signing,
})
}
void this.sync.sync({ sourceDescription: 'Not awaiting due to this event handler running from sync response' })
}
}

View File

@@ -0,0 +1,44 @@
import { ItemManagerInterface } from './../../Item/ItemManagerInterface'
import { MutatorClientInterface } from './../../Mutator/MutatorClientInterface'
import { SyncServiceInterface } from '../../Sync/SyncServiceInterface'
import {
KeySystemRootKeyInterface,
AsymmetricMessageSharedVaultRootKeyChanged,
FillItemContent,
KeySystemRootKeyContent,
VaultListingMutator,
} from '@standardnotes/models'
import { ContentType } from '@standardnotes/common'
import { GetVaultUseCase } from '../../Vaults/UseCase/GetVault'
import { EncryptionProviderInterface } from '@standardnotes/encryption'
export class HandleTrustedSharedVaultRootKeyChangedMessage {
constructor(
private mutator: MutatorClientInterface,
private items: ItemManagerInterface,
private sync: SyncServiceInterface,
private encryption: EncryptionProviderInterface,
) {}
async execute(message: AsymmetricMessageSharedVaultRootKeyChanged): Promise<void> {
const rootKeyContent = message.data.rootKey
await this.mutator.createItem<KeySystemRootKeyInterface>(
ContentType.KeySystemRootKey,
FillItemContent<KeySystemRootKeyContent>(rootKeyContent),
true,
)
const vault = new GetVaultUseCase(this.items).execute({ keySystemIdentifier: rootKeyContent.systemIdentifier })
if (vault) {
await this.mutator.changeItem<VaultListingMutator>(vault, (mutator) => {
mutator.rootKeyParams = rootKeyContent.keyParams
})
}
await this.encryption.decryptErroredPayloads()
void this.sync.sync({ sourceDescription: 'Not awaiting due to this event handler running from sync response' })
}
}

View File

@@ -0,0 +1,24 @@
import { ClientDisplayableError, isErrorResponse, AsymmetricMessageServerHash } from '@standardnotes/responses'
import { AsymmetricMessageServerInterface } from '@standardnotes/api'
export class SendAsymmetricMessageUseCase {
constructor(private messageServer: AsymmetricMessageServerInterface) {}
async execute(params: {
recipientUuid: string
encryptedMessage: string
replaceabilityIdentifier: string | undefined
}): Promise<AsymmetricMessageServerHash | ClientDisplayableError> {
const response = await this.messageServer.createMessage({
recipientUuid: params.recipientUuid,
encryptedMessage: params.encryptedMessage,
replaceabilityIdentifier: params.replaceabilityIdentifier,
})
if (isErrorResponse(response)) {
return ClientDisplayableError.FromError(response.data.error)
}
return response.data.message
}
}

View File

@@ -0,0 +1,47 @@
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import { AsymmetricMessageServerHash, ClientDisplayableError } from '@standardnotes/responses'
import {
TrustedContactInterface,
AsymmetricMessagePayloadType,
AsymmetricMessageSenderKeypairChanged,
} from '@standardnotes/models'
import { AsymmetricMessageServer } from '@standardnotes/api'
import { PkcKeyPair } from '@standardnotes/sncrypto-common'
import { SendAsymmetricMessageUseCase } from './SendAsymmetricMessageUseCase'
export class SendOwnContactChangeMessage {
constructor(private encryption: EncryptionProviderInterface, private messageServer: AsymmetricMessageServer) {}
async execute(params: {
senderOldKeyPair: PkcKeyPair
senderOldSigningKeyPair: PkcKeyPair
senderNewKeyPair: PkcKeyPair
senderNewSigningKeyPair: PkcKeyPair
contact: TrustedContactInterface
}): Promise<AsymmetricMessageServerHash | ClientDisplayableError> {
const message: AsymmetricMessageSenderKeypairChanged = {
type: AsymmetricMessagePayloadType.SenderKeypairChanged,
data: {
recipientUuid: params.contact.contactUuid,
newEncryptionPublicKey: params.senderNewKeyPair.publicKey,
newSigningPublicKey: params.senderNewSigningKeyPair.publicKey,
},
}
const encryptedMessage = this.encryption.asymmetricallyEncryptMessage({
message: message,
senderKeyPair: params.senderOldKeyPair,
senderSigningKeyPair: params.senderOldSigningKeyPair,
recipientPublicKey: params.contact.publicKeySet.encryption,
})
const sendMessageUseCase = new SendAsymmetricMessageUseCase(this.messageServer)
const sendMessageResult = await sendMessageUseCase.execute({
recipientUuid: params.contact.contactUuid,
encryptedMessage,
replaceabilityIdentifier: undefined,
})
return sendMessageResult
}
}

View File

@@ -32,16 +32,13 @@ describe('backup service', () => {
beforeEach(() => {
apiService = {} as jest.Mocked<ApiServiceInterface>
apiService.addEventObserver = jest.fn()
apiService.createFileValetToken = jest.fn()
apiService.createUserFileValetToken = jest.fn()
apiService.downloadFile = jest.fn()
apiService.deleteFile = jest.fn().mockReturnValue({})
itemManager = {} as jest.Mocked<ItemManagerInterface>
itemManager.createItem = jest.fn()
itemManager.createTemplateItem = jest.fn().mockReturnValue({})
itemManager.setItemToBeDeleted = jest.fn()
itemManager.addObserver = jest.fn()
itemManager.changeItem = jest.fn()
status = {} as jest.Mocked<StatusServiceInterface>

View File

@@ -515,7 +515,7 @@ export class FilesBackupService extends AbstractService implements BackupService
},
})
const token = await this.api.createFileValetToken(file.remoteIdentifier, 'read')
const token = await this.api.createUserFileValetToken(file.remoteIdentifier, 'read')
if (token instanceof ClientDisplayableError) {
this.status.removeMessage(messageId)
@@ -536,9 +536,11 @@ export class FilesBackupService extends AbstractService implements BackupService
const metaFileAsString = JSON.stringify(metaFile, null, 2)
const downloadType = !file.user_uuid || file.user_uuid === this.session.getSureUser().uuid ? 'user' : 'shared-vault'
const result = await this.device.saveFilesBackupsFile(location, file.uuid, metaFileAsString, {
chunkSizes: file.encryptedChunkSizes,
url: this.api.getFilesDownloadUrl(),
url: this.api.getFilesDownloadUrl(downloadType),
valetToken: token,
})

View File

@@ -1,3 +1,5 @@
import { ChallengeArtifacts } from './Types/ChallengeArtifacts'
import { ChallengeValue } from './Types/ChallengeValue'
import { RootKeyInterface } from '@standardnotes/models'
import { AbstractService } from '../Service/AbstractService'
@@ -5,6 +7,7 @@ import { ChallengeInterface } from './ChallengeInterface'
import { ChallengePromptInterface } from './Prompt/ChallengePromptInterface'
import { ChallengeResponseInterface } from './ChallengeResponseInterface'
import { ChallengeReason } from './Types/ChallengeReason'
import { ChallengeObserver } from './Types/ChallengeObserver'
export interface ChallengeServiceInterface extends AbstractService {
/**
@@ -20,7 +23,7 @@ export interface ChallengeServiceInterface extends AbstractService {
subheading?: string,
): ChallengeInterface
completeChallenge(challenge: ChallengeInterface): void
promptForAccountPassword(): Promise<boolean>
promptForAccountPassword(): Promise<string | null>
getWrappingKeyIfApplicable(passcode?: string): Promise<
| {
canceled?: undefined
@@ -35,4 +38,11 @@ export interface ChallengeServiceInterface extends AbstractService {
canceled?: undefined
}
>
addChallengeObserver(challenge: ChallengeInterface, observer: ChallengeObserver): () => void
setValidationStatusForChallenge(
challenge: ChallengeInterface,
value: ChallengeValue,
valid: boolean,
artifacts?: ChallengeArtifacts,
): void
}

View File

@@ -0,0 +1,10 @@
import { ChallengeResponseInterface } from '../ChallengeResponseInterface'
import { ChallengeValueCallback } from './ChallengeValueCallback'
export type ChallengeObserver = {
onValidValue?: ChallengeValueCallback
onInvalidValue?: ChallengeValueCallback
onNonvalidatedSubmit?: (response: ChallengeResponseInterface) => void
onComplete?: (response: ChallengeResponseInterface) => void
onCancel?: () => void
}

View File

@@ -0,0 +1,3 @@
import { ChallengeValue } from './ChallengeValue'
export type ChallengeValueCallback = (value: ChallengeValue) => void

View File

@@ -11,3 +11,5 @@ export * from './Types/ChallengeRawValue'
export * from './Types/ChallengeReason'
export * from './Types/ChallengeValidation'
export * from './Types/ChallengeValue'
export * from './Types/ChallengeObserver'
export * from './Types/ChallengeValueCallback'

View File

@@ -21,4 +21,6 @@ export interface ComponentManagerInterface {
presentPermissionsDialog(_dialog: PermissionDialog): void
legacyGetDefaultEditor(): SNComponent | undefined
componentWithIdentifier(identifier: FeatureIdentifier | string): SNComponent | undefined
toggleTheme(uuid: string): Promise<void>
toggleComponent(uuid: string): Promise<void>
}

View File

@@ -0,0 +1,8 @@
export const Version1CollaborationId = '1'
export type CollaborationIDData = {
version: string
userUuid: string
publicKey: string
signingPublicKey: string
}

View File

@@ -0,0 +1,264 @@
import { MutatorClientInterface } from './../Mutator/MutatorClientInterface'
import { ApplicationStage } from './../Application/ApplicationStage'
import { SingletonManagerInterface } from './../Singleton/SingletonManagerInterface'
import { UserKeyPairChangedEventData } from './../Session/UserKeyPairChangedEventData'
import { SessionEvent } from './../Session/SessionEvent'
import { InternalEventInterface } from './../Internal/InternalEventInterface'
import { InternalEventHandlerInterface } from './../Internal/InternalEventHandlerInterface'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { SharedVaultInviteServerHash, SharedVaultUserServerHash } from '@standardnotes/responses'
import {
TrustedContactContent,
TrustedContactContentSpecialized,
TrustedContactInterface,
FillItemContent,
TrustedContactMutator,
DecryptedItemInterface,
} from '@standardnotes/models'
import { ContentType } from '@standardnotes/common'
import { AbstractService } from '../Service/AbstractService'
import { SyncServiceInterface } from '../Sync/SyncServiceInterface'
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
import { SessionsClientInterface } from '../Session/SessionsClientInterface'
import { ContactServiceEvent, ContactServiceInterface } from '../Contacts/ContactServiceInterface'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { UserClientInterface } from '../User/UserClientInterface'
import { CollaborationIDData, Version1CollaborationId } from './CollaborationID'
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import { ValidateItemSignerUseCase } from './UseCase/ValidateItemSigner'
import { ValidateItemSignerResult } from './UseCase/ValidateItemSignerResult'
import { FindTrustedContactUseCase } from './UseCase/FindTrustedContact'
import { SelfContactManager } from './Managers/SelfContactManager'
import { CreateOrEditTrustedContactUseCase } from './UseCase/CreateOrEditTrustedContact'
import { UpdateTrustedContactUseCase } from './UseCase/UpdateTrustedContact'
export class ContactService
extends AbstractService<ContactServiceEvent>
implements ContactServiceInterface, InternalEventHandlerInterface
{
private selfContactManager: SelfContactManager
constructor(
private sync: SyncServiceInterface,
private items: ItemManagerInterface,
private mutator: MutatorClientInterface,
private session: SessionsClientInterface,
private crypto: PureCryptoInterface,
private user: UserClientInterface,
private encryption: EncryptionProviderInterface,
singletons: SingletonManagerInterface,
eventBus: InternalEventBusInterface,
) {
super(eventBus)
this.selfContactManager = new SelfContactManager(sync, items, mutator, session, singletons)
eventBus.addEventHandler(this, SessionEvent.UserKeyPairChanged)
}
public override async handleApplicationStage(stage: ApplicationStage): Promise<void> {
await super.handleApplicationStage(stage)
await this.selfContactManager.handleApplicationStage(stage)
}
async handleEvent(event: InternalEventInterface): Promise<void> {
if (event.type === SessionEvent.UserKeyPairChanged) {
const data = event.payload as UserKeyPairChangedEventData
await this.selfContactManager.updateWithNewPublicKeySet({
encryption: data.newKeyPair.publicKey,
signing: data.newSigningKeyPair.publicKey,
})
}
}
private get userUuid(): string {
return this.session.getSureUser().uuid
}
getSelfContact(): TrustedContactInterface | undefined {
return this.selfContactManager.selfContact
}
public isCollaborationEnabled(): boolean {
return !this.session.isUserMissingKeyPair()
}
public async enableCollaboration(): Promise<void> {
await this.user.updateAccountWithFirstTimeKeyPair()
}
public getCollaborationID(): string {
const publicKey = this.session.getPublicKey()
if (!publicKey) {
throw new Error('Collaboration not enabled')
}
return this.buildCollaborationId({
version: Version1CollaborationId,
userUuid: this.session.getSureUser().uuid,
publicKey,
signingPublicKey: this.session.getSigningPublicKey(),
})
}
private buildCollaborationId(params: CollaborationIDData): string {
const string = `${params.version}:${params.userUuid}:${params.publicKey}:${params.signingPublicKey}`
return this.crypto.base64Encode(string)
}
public parseCollaborationID(collaborationID: string): CollaborationIDData {
const decoded = this.crypto.base64Decode(collaborationID)
const [version, userUuid, publicKey, signingPublicKey] = decoded.split(':')
return { version, userUuid, publicKey, signingPublicKey }
}
public getCollaborationIDFromInvite(invite: SharedVaultInviteServerHash): string {
const publicKeySet = this.encryption.getSenderPublicKeySetFromAsymmetricallyEncryptedString(
invite.encrypted_message,
)
return this.buildCollaborationId({
version: Version1CollaborationId,
userUuid: invite.sender_uuid,
publicKey: publicKeySet.encryption,
signingPublicKey: publicKeySet.signing,
})
}
public addTrustedContactFromCollaborationID(
collaborationID: string,
name?: string,
): Promise<TrustedContactInterface | undefined> {
const { userUuid, publicKey, signingPublicKey } = this.parseCollaborationID(collaborationID)
return this.createOrEditTrustedContact({
name: name ?? '',
contactUuid: userUuid,
publicKey,
signingPublicKey,
})
}
async editTrustedContactFromCollaborationID(
contact: TrustedContactInterface,
params: { name: string; collaborationID: string },
): Promise<TrustedContactInterface> {
const { publicKey, signingPublicKey, userUuid } = this.parseCollaborationID(params.collaborationID)
if (userUuid !== contact.contactUuid) {
throw new Error("Collaboration ID's user uuid does not match contact UUID")
}
const updatedContact = await this.mutator.changeItem<TrustedContactMutator, TrustedContactInterface>(
contact,
(mutator) => {
mutator.name = params.name
if (publicKey !== contact.publicKeySet.encryption || signingPublicKey !== contact.publicKeySet.signing) {
mutator.addPublicKey({
encryption: publicKey,
signing: signingPublicKey,
})
}
},
)
await this.sync.sync()
return updatedContact
}
async updateTrustedContact(
contact: TrustedContactInterface,
params: { name: string; publicKey: string; signingPublicKey: string },
): Promise<TrustedContactInterface> {
const usecase = new UpdateTrustedContactUseCase(this.mutator, this.sync)
const updatedContact = await usecase.execute(contact, params)
return updatedContact
}
async createOrUpdateTrustedContactFromContactShare(
data: TrustedContactContentSpecialized,
): Promise<TrustedContactInterface> {
if (data.contactUuid === this.userUuid) {
throw new Error('Cannot receive self from contact share')
}
let contact = this.findTrustedContact(data.contactUuid)
if (contact) {
contact = await this.mutator.changeItem<TrustedContactMutator, TrustedContactInterface>(contact, (mutator) => {
mutator.name = data.name
mutator.replacePublicKeySet(data.publicKeySet)
})
} else {
contact = await this.mutator.createItem<TrustedContactInterface>(
ContentType.TrustedContact,
FillItemContent<TrustedContactContent>(data),
true,
)
}
await this.sync.sync()
return contact
}
async createOrEditTrustedContact(params: {
name?: string
contactUuid: string
publicKey: string
signingPublicKey: string
isMe?: boolean
}): Promise<TrustedContactInterface | undefined> {
const usecase = new CreateOrEditTrustedContactUseCase(this.items, this.mutator, this.sync)
const contact = await usecase.execute(params)
return contact
}
async deleteContact(contact: TrustedContactInterface): Promise<void> {
if (contact.isMe) {
throw new Error('Cannot delete self')
}
await this.mutator.setItemToBeDeleted(contact)
await this.sync.sync()
}
getAllContacts(): TrustedContactInterface[] {
return this.items.getItems(ContentType.TrustedContact)
}
findTrustedContact(userUuid: string): TrustedContactInterface | undefined {
const usecase = new FindTrustedContactUseCase(this.items)
return usecase.execute({ userUuid })
}
findTrustedContactForServerUser(user: SharedVaultUserServerHash): TrustedContactInterface | undefined {
return this.findTrustedContact(user.user_uuid)
}
findTrustedContactForInvite(invite: SharedVaultInviteServerHash): TrustedContactInterface | undefined {
return this.findTrustedContact(invite.user_uuid)
}
getCollaborationIDForTrustedContact(contact: TrustedContactInterface): string {
return this.buildCollaborationId({
version: Version1CollaborationId,
userUuid: contact.content.contactUuid,
publicKey: contact.content.publicKeySet.encryption,
signingPublicKey: contact.content.publicKeySet.signing,
})
}
isItemAuthenticallySigned(item: DecryptedItemInterface): ValidateItemSignerResult {
const usecase = new ValidateItemSignerUseCase(this.items)
return usecase.execute(item)
}
override deinit(): void {
super.deinit()
this.selfContactManager.deinit()
;(this.sync as unknown) = undefined
;(this.items as unknown) = undefined
;(this.selfContactManager as unknown) = undefined
}
}

View File

@@ -0,0 +1,43 @@
import {
DecryptedItemInterface,
TrustedContactContentSpecialized,
TrustedContactInterface,
} from '@standardnotes/models'
import { AbstractService } from '../Service/AbstractService'
import { SharedVaultInviteServerHash, SharedVaultUserServerHash } from '@standardnotes/responses'
import { ValidateItemSignerResult } from './UseCase/ValidateItemSignerResult'
export enum ContactServiceEvent {}
export interface ContactServiceInterface extends AbstractService<ContactServiceEvent> {
isCollaborationEnabled(): boolean
enableCollaboration(): Promise<void>
getCollaborationID(): string
getCollaborationIDFromInvite(invite: SharedVaultInviteServerHash): string
addTrustedContactFromCollaborationID(
collaborationID: string,
name?: string,
): Promise<TrustedContactInterface | undefined>
getCollaborationIDForTrustedContact(contact: TrustedContactInterface): string
createOrEditTrustedContact(params: {
contactUuid: string
name?: string
publicKey: string
signingPublicKey: string
}): Promise<TrustedContactInterface | undefined>
createOrUpdateTrustedContactFromContactShare(data: TrustedContactContentSpecialized): Promise<TrustedContactInterface>
editTrustedContactFromCollaborationID(
contact: TrustedContactInterface,
params: { name: string; collaborationID: string },
): Promise<TrustedContactInterface>
deleteContact(contact: TrustedContactInterface): Promise<void>
getAllContacts(): TrustedContactInterface[]
getSelfContact(): TrustedContactInterface | undefined
findTrustedContact(userUuid: string): TrustedContactInterface | undefined
findTrustedContactForServerUser(user: SharedVaultUserServerHash): TrustedContactInterface | undefined
findTrustedContactForInvite(invite: SharedVaultInviteServerHash): TrustedContactInterface | undefined
isItemAuthenticallySigned(item: DecryptedItemInterface): ValidateItemSignerResult
}

View File

@@ -0,0 +1,129 @@
import { MutatorClientInterface } from './../../Mutator/MutatorClientInterface'
import { InternalFeature } from './../../InternalFeatures/InternalFeature'
import { InternalFeatureService } from '../../InternalFeatures/InternalFeatureService'
import { ApplicationStage } from './../../Application/ApplicationStage'
import { SingletonManagerInterface } from './../../Singleton/SingletonManagerInterface'
import { SyncEvent } from './../../Event/SyncEvent'
import { SessionsClientInterface } from '../../Session/SessionsClientInterface'
import { ItemManagerInterface } from '../../Item/ItemManagerInterface'
import { SyncServiceInterface } from '../../Sync/SyncServiceInterface'
import {
ContactPublicKeySet,
FillItemContent,
TrustedContact,
TrustedContactContent,
TrustedContactContentSpecialized,
TrustedContactInterface,
} from '@standardnotes/models'
import { ContentType } from '@standardnotes/common'
import { CreateOrEditTrustedContactUseCase } from '../UseCase/CreateOrEditTrustedContact'
import { PublicKeySet } from '@standardnotes/encryption'
export class SelfContactManager {
public selfContact?: TrustedContactInterface
private shouldReloadSelfContact = true
private isReloadingSelfContact = false
private eventDisposers: (() => void)[] = []
constructor(
private sync: SyncServiceInterface,
private items: ItemManagerInterface,
private mutator: MutatorClientInterface,
private session: SessionsClientInterface,
private singletons: SingletonManagerInterface,
) {
this.eventDisposers.push(
items.addObserver(ContentType.TrustedContact, () => {
this.shouldReloadSelfContact = true
}),
)
this.eventDisposers.push(
sync.addEventObserver((event) => {
if (event === SyncEvent.SyncCompletedWithAllItemsUploaded || event === SyncEvent.LocalDataIncrementalLoad) {
void this.reloadSelfContact()
}
}),
)
}
public async handleApplicationStage(stage: ApplicationStage): Promise<void> {
if (stage === ApplicationStage.LoadedDatabase_12) {
this.selfContact = this.singletons.findSingleton<TrustedContactInterface>(
ContentType.UserPrefs,
TrustedContact.singletonPredicate,
)
}
}
public async updateWithNewPublicKeySet(publicKeySet: PublicKeySet) {
if (!InternalFeatureService.get().isFeatureEnabled(InternalFeature.Vaults)) {
return
}
if (!this.selfContact) {
return
}
const usecase = new CreateOrEditTrustedContactUseCase(this.items, this.mutator, this.sync)
await usecase.execute({
name: 'Me',
contactUuid: this.selfContact.contactUuid,
publicKey: publicKeySet.encryption,
signingPublicKey: publicKeySet.signing,
})
}
private async reloadSelfContact() {
if (!InternalFeatureService.get().isFeatureEnabled(InternalFeature.Vaults)) {
return
}
if (!this.shouldReloadSelfContact || this.isReloadingSelfContact) {
return
}
if (!this.session.isSignedIn()) {
return
}
if (this.session.isUserMissingKeyPair()) {
return
}
this.isReloadingSelfContact = true
const content: TrustedContactContentSpecialized = {
name: 'Me',
isMe: true,
contactUuid: this.session.getSureUser().uuid,
publicKeySet: ContactPublicKeySet.FromJson({
encryption: this.session.getPublicKey(),
signing: this.session.getSigningPublicKey(),
isRevoked: false,
timestamp: new Date(),
}),
}
try {
this.selfContact = await this.singletons.findOrCreateSingleton<TrustedContactContent, TrustedContact>(
TrustedContact.singletonPredicate,
ContentType.TrustedContact,
FillItemContent<TrustedContactContent>(content),
)
this.shouldReloadSelfContact = false
} finally {
this.isReloadingSelfContact = false
}
}
deinit() {
this.eventDisposers.forEach((disposer) => disposer())
;(this.sync as unknown) = undefined
;(this.items as unknown) = undefined
;(this.mutator as unknown) = undefined
;(this.session as unknown) = undefined
;(this.singletons as unknown) = undefined
}
}

View File

@@ -0,0 +1 @@
export const UnknownContactName = 'Unnamed contact'

View File

@@ -0,0 +1,61 @@
import { SyncServiceInterface } from './../../Sync/SyncServiceInterface'
import { MutatorClientInterface } from './../../Mutator/MutatorClientInterface'
import { ItemManagerInterface } from './../../Item/ItemManagerInterface'
import {
ContactPublicKeySet,
FillItemContent,
TrustedContactContent,
TrustedContactContentSpecialized,
TrustedContactInterface,
} from '@standardnotes/models'
import { FindTrustedContactUseCase } from './FindTrustedContact'
import { UnknownContactName } from '../UnknownContactName'
import { ContentType } from '@standardnotes/common'
import { UpdateTrustedContactUseCase } from './UpdateTrustedContact'
export class CreateOrEditTrustedContactUseCase {
constructor(
private items: ItemManagerInterface,
private mutator: MutatorClientInterface,
private sync: SyncServiceInterface,
) {}
async execute(params: {
name?: string
contactUuid: string
publicKey: string
signingPublicKey: string
isMe?: boolean
}): Promise<TrustedContactInterface | undefined> {
const findUsecase = new FindTrustedContactUseCase(this.items)
const existingContact = findUsecase.execute({ userUuid: params.contactUuid })
if (existingContact) {
const updateUsecase = new UpdateTrustedContactUseCase(this.mutator, this.sync)
await updateUsecase.execute(existingContact, { ...params, name: params.name ?? existingContact.name })
return existingContact
}
const content: TrustedContactContentSpecialized = {
name: params.name ?? UnknownContactName,
publicKeySet: ContactPublicKeySet.FromJson({
encryption: params.publicKey,
signing: params.signingPublicKey,
isRevoked: false,
timestamp: new Date(),
}),
contactUuid: params.contactUuid,
isMe: params.isMe ?? false,
}
const contact = await this.mutator.createItem<TrustedContactInterface>(
ContentType.TrustedContact,
FillItemContent<TrustedContactContent>(content),
true,
)
await this.sync.sync()
return contact
}
}

View File

@@ -0,0 +1 @@
export type FindContactQuery = { userUuid: string } | { signingPublicKey: string } | { publicKey: string }

View File

@@ -0,0 +1,29 @@
import { Predicate, TrustedContactInterface } from '@standardnotes/models'
import { ItemManagerInterface } from './../../Item/ItemManagerInterface'
import { ContentType } from '@standardnotes/common'
import { FindContactQuery } from './FindContactQuery'
export class FindTrustedContactUseCase {
constructor(private items: ItemManagerInterface) {}
execute(query: FindContactQuery): TrustedContactInterface | undefined {
if ('userUuid' in query && query.userUuid) {
return this.items.itemsMatchingPredicate<TrustedContactInterface>(
ContentType.TrustedContact,
new Predicate<TrustedContactInterface>('contactUuid', '=', query.userUuid),
)[0]
}
if ('signingPublicKey' in query && query.signingPublicKey) {
const allContacts = this.items.getItems<TrustedContactInterface>(ContentType.TrustedContact)
return allContacts.find((contact) => contact.isSigningKeyTrusted(query.signingPublicKey))
}
if ('publicKey' in query && query.publicKey) {
const allContacts = this.items.getItems<TrustedContactInterface>(ContentType.TrustedContact)
return allContacts.find((contact) => contact.isPublicKeyTrusted(query.publicKey))
}
throw new Error('Invalid query')
}
}

View File

@@ -0,0 +1,32 @@
import { SyncServiceInterface } from './../../Sync/SyncServiceInterface'
import { MutatorClientInterface } from './../../Mutator/MutatorClientInterface'
import { TrustedContactInterface, TrustedContactMutator } from '@standardnotes/models'
export class UpdateTrustedContactUseCase {
constructor(private mutator: MutatorClientInterface, private sync: SyncServiceInterface) {}
async execute(
contact: TrustedContactInterface,
params: { name: string; publicKey: string; signingPublicKey: string },
): Promise<TrustedContactInterface> {
const updatedContact = await this.mutator.changeItem<TrustedContactMutator, TrustedContactInterface>(
contact,
(mutator) => {
mutator.name = params.name
if (
params.publicKey !== contact.publicKeySet.encryption ||
params.signingPublicKey !== contact.publicKeySet.signing
) {
mutator.addPublicKey({
encryption: params.publicKey,
signing: params.signingPublicKey,
})
}
},
)
await this.sync.sync()
return updatedContact
}
}

View File

@@ -0,0 +1,347 @@
import {
DecryptedItemInterface,
PayloadSource,
PersistentSignatureData,
TrustedContactInterface,
} from '@standardnotes/models'
import { ItemManagerInterface } from './../../Item/ItemManagerInterface'
import { ValidateItemSignerUseCase } from './ValidateItemSigner'
describe('validate item signer use case', () => {
let usecase: ValidateItemSignerUseCase
let items: ItemManagerInterface
const trustedContact = {} as jest.Mocked<TrustedContactInterface>
trustedContact.isSigningKeyTrusted = jest.fn().mockReturnValue(true)
beforeEach(() => {
items = {} as jest.Mocked<ItemManagerInterface>
usecase = new ValidateItemSignerUseCase(items)
})
const createItem = (params: {
last_edited_by_uuid: string | undefined
shared_vault_uuid: string | undefined
signatureData: PersistentSignatureData | undefined
source?: PayloadSource
}): jest.Mocked<DecryptedItemInterface> => {
const payload = {
source: params.source ?? PayloadSource.RemoteRetrieved,
} as jest.Mocked<DecryptedItemInterface['payload']>
const item = {
last_edited_by_uuid: params.last_edited_by_uuid,
shared_vault_uuid: params.shared_vault_uuid,
signatureData: params.signatureData,
payload: payload,
} as unknown as jest.Mocked<DecryptedItemInterface>
return item
}
describe('has last edited by uuid', () => {
describe('trusted contact not found', () => {
beforeEach(() => {
items.itemsMatchingPredicate = jest.fn().mockReturnValue([])
})
it('should return invalid signing is required', () => {
const item = createItem({
last_edited_by_uuid: 'uuid-123',
shared_vault_uuid: 'shared-vault-123',
signatureData: undefined,
})
const result = usecase.execute(item)
expect(result).toEqual('no')
})
it('should return not applicable signing is not required', () => {
const item = createItem({
last_edited_by_uuid: 'uuid-123',
shared_vault_uuid: undefined,
signatureData: undefined,
})
const result = usecase.execute(item)
expect(result).toEqual('not-applicable')
})
})
describe('trusted contact found for last editor', () => {
beforeEach(() => {
items.itemsMatchingPredicate = jest.fn().mockReturnValue([trustedContact])
})
describe('does not have signature data', () => {
it('should return not applicable if the item was just recently created', () => {
const item = createItem({
last_edited_by_uuid: 'uuid-123',
shared_vault_uuid: 'shared-vault-123',
signatureData: undefined,
source: PayloadSource.Constructor,
})
const result = usecase.execute(item)
expect(result).toEqual('not-applicable')
})
it('should return not applicable if the item was just recently saved', () => {
const item = createItem({
last_edited_by_uuid: 'uuid-123',
shared_vault_uuid: 'shared-vault-123',
signatureData: undefined,
source: PayloadSource.RemoteSaved,
})
const result = usecase.execute(item)
expect(result).toEqual('not-applicable')
})
it('should return invalid if signing is required', () => {
const item = createItem({
last_edited_by_uuid: 'uuid-123',
shared_vault_uuid: 'shared-vault-123',
signatureData: undefined,
})
const result = usecase.execute(item)
expect(result).toEqual('no')
})
it('should return not applicable if signing is not required', () => {
const item = createItem({
last_edited_by_uuid: 'uuid-123',
signatureData: undefined,
shared_vault_uuid: undefined,
})
const result = usecase.execute(item)
expect(result).toEqual('not-applicable')
})
})
describe('has signature data', () => {
describe('signature data does not have result', () => {
it('should return invalid if signing is required', () => {
const item = createItem({
last_edited_by_uuid: 'uuid-123',
shared_vault_uuid: 'shared-vault-123',
signatureData: {
required: true,
} as jest.Mocked<PersistentSignatureData>,
})
const result = usecase.execute(item)
expect(result).toEqual('no')
})
it('should return not applicable if signing is not required', () => {
const item = createItem({
last_edited_by_uuid: 'uuid-123',
shared_vault_uuid: undefined,
signatureData: {
required: false,
} as jest.Mocked<PersistentSignatureData>,
})
const result = usecase.execute(item)
expect(result).toEqual('not-applicable')
})
})
describe('signature data has result', () => {
it('should return invalid if signature result does not pass', () => {
const item = createItem({
last_edited_by_uuid: 'uuid-123',
shared_vault_uuid: 'shared-vault-123',
signatureData: {
required: true,
result: {
passes: false,
},
} as jest.Mocked<PersistentSignatureData>,
})
const result = usecase.execute(item)
expect(result).toEqual('no')
})
it('should return invalid if signature result passes and a trusted contact is NOT found for signature public key', () => {
const item = createItem({
last_edited_by_uuid: 'uuid-123',
shared_vault_uuid: 'shared-vault-123',
signatureData: {
required: true,
result: {
passes: true,
publicKey: 'pk-123',
},
} as jest.Mocked<PersistentSignatureData>,
})
items.itemsMatchingPredicate = jest.fn().mockReturnValue([])
const result = usecase.execute(item)
expect(result).toEqual('no')
})
it('should return valid if signature result passes and a trusted contact is found for signature public key', () => {
const item = createItem({
last_edited_by_uuid: 'uuid-123',
shared_vault_uuid: 'shared-vault-123',
signatureData: {
required: true,
result: {
passes: true,
publicKey: 'pk-123',
},
} as jest.Mocked<PersistentSignatureData>,
})
items.itemsMatchingPredicate = jest.fn().mockReturnValue([trustedContact])
const result = usecase.execute(item)
expect(result).toEqual('yes')
})
})
})
})
})
describe('has no last edited by uuid', () => {
describe('does not have signature data', () => {
it('should return not applicable if the item was just recently created', () => {
const item = createItem({
last_edited_by_uuid: undefined,
shared_vault_uuid: 'shared-vault-123',
signatureData: undefined,
source: PayloadSource.Constructor,
})
const result = usecase.execute(item)
expect(result).toEqual('not-applicable')
})
it('should return not applicable if the item was just recently saved', () => {
const item = createItem({
last_edited_by_uuid: undefined,
shared_vault_uuid: 'shared-vault-123',
signatureData: undefined,
source: PayloadSource.RemoteSaved,
})
const result = usecase.execute(item)
expect(result).toEqual('not-applicable')
})
it('should return invalid if signing is required', () => {
const item = createItem({
last_edited_by_uuid: undefined,
shared_vault_uuid: 'shared-vault-123',
signatureData: undefined,
})
const result = usecase.execute(item)
expect(result).toEqual('no')
})
it('should return not applicable if signing is not required', () => {
const item = createItem({
last_edited_by_uuid: undefined,
shared_vault_uuid: undefined,
signatureData: undefined,
})
const result = usecase.execute(item)
expect(result).toEqual('not-applicable')
})
})
describe('has signature data', () => {
describe('signature data does not have result', () => {
it('should return invalid if signing is required', () => {
const item = createItem({
last_edited_by_uuid: undefined,
shared_vault_uuid: 'shared-vault-123',
signatureData: {
required: true,
} as jest.Mocked<PersistentSignatureData>,
})
const result = usecase.execute(item)
expect(result).toEqual('no')
})
it('should return not applicable if signing is not required', () => {
const item = createItem({
last_edited_by_uuid: undefined,
shared_vault_uuid: undefined,
signatureData: {
required: false,
} as jest.Mocked<PersistentSignatureData>,
})
const result = usecase.execute(item)
expect(result).toEqual('not-applicable')
})
})
describe('signature data has result', () => {
it('should return invalid if signature result does not pass', () => {
const item = createItem({
last_edited_by_uuid: undefined,
shared_vault_uuid: 'shared-vault-123',
signatureData: {
required: true,
result: {
passes: false,
},
} as jest.Mocked<PersistentSignatureData>,
})
const result = usecase.execute(item)
expect(result).toEqual('no')
})
it('should return invalid if signature result passes and a trusted contact is NOT found for signature public key', () => {
const item = createItem({
last_edited_by_uuid: undefined,
shared_vault_uuid: 'shared-vault-123',
signatureData: {
required: true,
result: {
passes: true,
publicKey: 'pk-123',
},
} as jest.Mocked<PersistentSignatureData>,
})
items.getItems = jest.fn().mockReturnValue([])
const result = usecase.execute(item)
expect(result).toEqual('no')
})
it('should return valid if signature result passes and a trusted contact is found for signature public key', () => {
const item = createItem({
last_edited_by_uuid: undefined,
shared_vault_uuid: 'shared-vault-123',
signatureData: {
required: true,
result: {
passes: true,
publicKey: 'pk-123',
},
} as jest.Mocked<PersistentSignatureData>,
})
items.getItems = jest.fn().mockReturnValue([trustedContact])
const result = usecase.execute(item)
expect(result).toEqual('yes')
})
})
})
})
})

View File

@@ -0,0 +1,122 @@
import { ItemManagerInterface } from './../../Item/ItemManagerInterface'
import { doesPayloadRequireSigning } from '@standardnotes/encryption/src/Domain/Operator/004/V004AlgorithmHelpers'
import { DecryptedItemInterface, PayloadSource } from '@standardnotes/models'
import { ValidateItemSignerResult } from './ValidateItemSignerResult'
import { FindTrustedContactUseCase } from './FindTrustedContact'
export class ValidateItemSignerUseCase {
private findContactUseCase = new FindTrustedContactUseCase(this.items)
constructor(private items: ItemManagerInterface) {}
execute(item: DecryptedItemInterface): ValidateItemSignerResult {
const uuidOfLastEditor = item.last_edited_by_uuid
if (uuidOfLastEditor) {
return this.validateSignatureWithLastEditedByUuid(item, uuidOfLastEditor)
} else {
return this.validateSignatureWithNoLastEditedByUuid(item)
}
}
private isItemLocallyCreatedAndDoesNotRequireSignature(item: DecryptedItemInterface): boolean {
return item.payload.source === PayloadSource.Constructor
}
private isItemResutOfRemoteSaveAndDoesNotRequireSignature(item: DecryptedItemInterface): boolean {
return item.payload.source === PayloadSource.RemoteSaved
}
private validateSignatureWithLastEditedByUuid(
item: DecryptedItemInterface,
uuidOfLastEditor: string,
): ValidateItemSignerResult {
const requiresSignature = doesPayloadRequireSigning(item)
const trustedContact = this.findContactUseCase.execute({ userUuid: uuidOfLastEditor })
if (!trustedContact) {
if (requiresSignature) {
return 'no'
} else {
return 'not-applicable'
}
}
if (!item.signatureData) {
if (
this.isItemLocallyCreatedAndDoesNotRequireSignature(item) ||
this.isItemResutOfRemoteSaveAndDoesNotRequireSignature(item)
) {
return 'not-applicable'
}
if (requiresSignature) {
return 'no'
}
return 'not-applicable'
}
const signatureData = item.signatureData
if (!signatureData.result) {
if (signatureData.required) {
return 'no'
}
return 'not-applicable'
}
const signatureResult = signatureData.result
if (!signatureResult.passes) {
return 'no'
}
const signerPublicKey = signatureResult.publicKey
if (trustedContact.isSigningKeyTrusted(signerPublicKey)) {
return 'yes'
}
return 'no'
}
private validateSignatureWithNoLastEditedByUuid(item: DecryptedItemInterface): ValidateItemSignerResult {
const requiresSignature = doesPayloadRequireSigning(item)
if (!item.signatureData) {
if (
this.isItemLocallyCreatedAndDoesNotRequireSignature(item) ||
this.isItemResutOfRemoteSaveAndDoesNotRequireSignature(item)
) {
return 'not-applicable'
}
if (requiresSignature) {
return 'no'
}
return 'not-applicable'
}
const signatureData = item.signatureData
if (!signatureData.result) {
if (signatureData.required) {
return 'no'
}
return 'not-applicable'
}
const signatureResult = signatureData.result
if (!signatureResult.passes) {
return 'no'
}
const signerPublicKey = signatureResult.publicKey
const trustedContact = this.findContactUseCase.execute({ signingPublicKey: signerPublicKey })
if (trustedContact) {
return 'yes'
}
return 'no'
}
}

View File

@@ -0,0 +1 @@
export type ValidateItemSignerResult = 'not-applicable' | 'yes' | 'no'

View File

@@ -18,6 +18,8 @@ export function isChunkFullEntry(
export type DatabaseKeysLoadChunkResponse = {
keys: {
itemsKeys: DatabaseKeysLoadChunk
keySystemRootKeys: DatabaseKeysLoadChunk
keySystemItemsKeys: DatabaseKeysLoadChunk
remainingChunks: DatabaseKeysLoadChunk[]
}
remainingChunksItemCount: number
@@ -26,6 +28,8 @@ export type DatabaseKeysLoadChunkResponse = {
export type DatabaseFullEntryLoadChunkResponse = {
fullEntries: {
itemsKeys: DatabaseFullEntryLoadChunk
keySystemRootKeys: DatabaseFullEntryLoadChunk
keySystemItemsKeys: DatabaseFullEntryLoadChunk
remainingChunks: DatabaseFullEntryLoadChunk[]
}
remainingChunksItemCount: number

View File

@@ -83,17 +83,25 @@ export function GetSortedPayloadsByPriority<T extends DatabaseItemMetadata = Dat
options: DatabaseLoadOptions,
): {
itemsKeyPayloads: T[]
keySystemRootKeyPayloads: T[]
keySystemItemsKeyPayloads: T[]
contentTypePriorityPayloads: T[]
remainingPayloads: T[]
} {
const itemsKeyPayloads: T[] = []
const keySystemRootKeyPayloads: T[] = []
const keySystemItemsKeyPayloads: T[] = []
const contentTypePriorityPayloads: T[] = []
const remainingPayloads: T[] = []
for (let index = 0; index < payloads.length; index++) {
const payload = payloads[index]
if (payload.content_type === ContentType.ItemsKey) {
if (payload.content_type === ContentType.KeySystemRootKey) {
keySystemRootKeyPayloads.push(payload)
} else if (payload.content_type === ContentType.KeySystemItemsKey) {
keySystemItemsKeyPayloads.push(payload)
} else if (payload.content_type === ContentType.ItemsKey) {
itemsKeyPayloads.push(payload)
} else if (options.contentTypePriority.includes(payload.content_type)) {
contentTypePriorityPayloads.push(payload)
@@ -104,6 +112,8 @@ export function GetSortedPayloadsByPriority<T extends DatabaseItemMetadata = Dat
return {
itemsKeyPayloads,
keySystemRootKeyPayloads,
keySystemItemsKeyPayloads,
contentTypePriorityPayloads: SortPayloadsByRecentAndContentPriority(
contentTypePriorityPayloads,
options.contentTypePriority,

View File

@@ -1,251 +0,0 @@
import {
AnyKeyParamsContent,
compareVersions,
ContentType,
leftVersionGreaterThanOrEqualToRight,
ProtocolVersion,
} from '@standardnotes/common'
import {
BackupFileType,
ContentTypeUsesRootKeyEncryption,
CreateAnyKeyParams,
isItemsKey,
SNItemsKey,
SNRootKey,
SNRootKeyParams,
} from '@standardnotes/encryption'
import {
BackupFile,
CreateDecryptedItemFromPayload,
CreatePayloadSplit,
DecryptedPayload,
DecryptedPayloadInterface,
EncryptedPayload,
EncryptedPayloadInterface,
isDecryptedPayload,
isDecryptedTransferPayload,
isEncryptedPayload,
isEncryptedTransferPayload,
ItemsKeyContent,
ItemsKeyInterface,
PayloadInterface,
} from '@standardnotes/models'
import { ClientDisplayableError } from '@standardnotes/responses'
import { extendArray } from '@standardnotes/utils'
import { EncryptionService } from './EncryptionService'
export async function DecryptBackupFile(
file: BackupFile,
protocolService: EncryptionService,
password?: string,
): Promise<ClientDisplayableError | (EncryptedPayloadInterface | DecryptedPayloadInterface)[]> {
const payloads: (EncryptedPayloadInterface | DecryptedPayloadInterface)[] = file.items.map((item) => {
if (isEncryptedTransferPayload(item)) {
return new EncryptedPayload(item)
} else if (isDecryptedTransferPayload(item)) {
return new DecryptedPayload(item)
} else {
throw Error('Unhandled case in decryptBackupFile')
}
})
const { encrypted, decrypted } = CreatePayloadSplit(payloads)
const type = getBackupFileType(file, payloads)
switch (type) {
case BackupFileType.Corrupt:
return new ClientDisplayableError('Invalid backup file.')
case BackupFileType.Encrypted: {
if (!password) {
throw Error('Attempting to decrypt encrypted file with no password')
}
const keyParamsData = (file.keyParams || file.auth_params) as AnyKeyParamsContent
return [
...decrypted,
...(await decryptEncrypted(password, CreateAnyKeyParams(keyParamsData), encrypted, protocolService)),
]
}
case BackupFileType.EncryptedWithNonEncryptedItemsKey:
return [...decrypted, ...(await decryptEncryptedWithNonEncryptedItemsKey(payloads, protocolService))]
case BackupFileType.FullyDecrypted:
return [...decrypted, ...encrypted]
}
}
function getBackupFileType(file: BackupFile, payloads: PayloadInterface[]): BackupFileType {
if (file.keyParams || file.auth_params) {
return BackupFileType.Encrypted
} else {
const hasEncryptedItem = payloads.find(isEncryptedPayload)
const hasDecryptedItemsKey = payloads.find(
(payload) => payload.content_type === ContentType.ItemsKey && isDecryptedPayload(payload),
)
if (hasEncryptedItem && hasDecryptedItemsKey) {
return BackupFileType.EncryptedWithNonEncryptedItemsKey
} else if (!hasEncryptedItem) {
return BackupFileType.FullyDecrypted
} else {
return BackupFileType.Corrupt
}
}
}
async function decryptEncryptedWithNonEncryptedItemsKey(
allPayloads: (EncryptedPayloadInterface | DecryptedPayloadInterface)[],
protocolService: EncryptionService,
): Promise<(EncryptedPayloadInterface | DecryptedPayloadInterface)[]> {
const decryptedItemsKeys: DecryptedPayloadInterface<ItemsKeyContent>[] = []
const encryptedPayloads: EncryptedPayloadInterface[] = []
allPayloads.forEach((payload) => {
if (payload.content_type === ContentType.ItemsKey && isDecryptedPayload(payload)) {
decryptedItemsKeys.push(payload as DecryptedPayloadInterface<ItemsKeyContent>)
} else if (isEncryptedPayload(payload)) {
encryptedPayloads.push(payload)
}
})
const itemsKeys = decryptedItemsKeys.map((p) => CreateDecryptedItemFromPayload<ItemsKeyContent, SNItemsKey>(p))
return decryptWithItemsKeys(encryptedPayloads, itemsKeys, protocolService)
}
function findKeyToUseForPayload(
payload: EncryptedPayloadInterface,
availableKeys: ItemsKeyInterface[],
protocolService: EncryptionService,
keyParams?: SNRootKeyParams,
fallbackRootKey?: SNRootKey,
): ItemsKeyInterface | SNRootKey | undefined {
let itemsKey: ItemsKeyInterface | SNRootKey | undefined
if (payload.items_key_id) {
itemsKey = protocolService.itemsKeyForPayload(payload)
if (itemsKey) {
return itemsKey
}
}
itemsKey = availableKeys.find((itemsKeyPayload) => {
return payload.items_key_id === itemsKeyPayload.uuid
})
if (itemsKey) {
return itemsKey
}
if (!keyParams) {
return undefined
}
const payloadVersion = payload.version as ProtocolVersion
/**
* Payloads with versions <= 003 use root key directly for encryption.
* However, if the incoming key params are >= 004, this means we should
* have an items key based off the 003 root key. We can't use the 004
* root key directly because it's missing dataAuthenticationKey.
*/
if (leftVersionGreaterThanOrEqualToRight(keyParams.version, ProtocolVersion.V004)) {
itemsKey = protocolService.defaultItemsKeyForItemVersion(payloadVersion, availableKeys)
} else if (compareVersions(payloadVersion, ProtocolVersion.V003) <= 0) {
itemsKey = fallbackRootKey
}
return itemsKey
}
async function decryptWithItemsKeys(
payloads: EncryptedPayloadInterface[],
itemsKeys: ItemsKeyInterface[],
protocolService: EncryptionService,
keyParams?: SNRootKeyParams,
fallbackRootKey?: SNRootKey,
): Promise<(DecryptedPayloadInterface | EncryptedPayloadInterface)[]> {
const results: (DecryptedPayloadInterface | EncryptedPayloadInterface)[] = []
for (const encryptedPayload of payloads) {
if (ContentTypeUsesRootKeyEncryption(encryptedPayload.content_type)) {
continue
}
try {
const key = findKeyToUseForPayload(encryptedPayload, itemsKeys, protocolService, keyParams, fallbackRootKey)
if (!key) {
results.push(
encryptedPayload.copy({
errorDecrypting: true,
}),
)
continue
}
if (isItemsKey(key)) {
const decryptedPayload = await protocolService.decryptSplitSingle({
usesItemsKey: {
items: [encryptedPayload],
key: key,
},
})
results.push(decryptedPayload)
} else {
const decryptedPayload = await protocolService.decryptSplitSingle({
usesRootKey: {
items: [encryptedPayload],
key: key,
},
})
results.push(decryptedPayload)
}
} catch (e) {
results.push(
encryptedPayload.copy({
errorDecrypting: true,
}),
)
console.error('Error decrypting payload', encryptedPayload, e)
}
}
return results
}
async function decryptEncrypted(
password: string,
keyParams: SNRootKeyParams,
payloads: EncryptedPayloadInterface[],
protocolService: EncryptionService,
): Promise<(EncryptedPayloadInterface | DecryptedPayloadInterface)[]> {
const results: (EncryptedPayloadInterface | DecryptedPayloadInterface)[] = []
const rootKey = await protocolService.computeRootKey(password, keyParams)
const itemsKeysPayloads = payloads.filter((payload) => {
return payload.content_type === ContentType.ItemsKey
})
const itemsKeysDecryptionResults = await protocolService.decryptSplit({
usesRootKey: {
items: itemsKeysPayloads,
key: rootKey,
},
})
extendArray(results, itemsKeysDecryptionResults)
const decryptedPayloads = await decryptWithItemsKeys(
payloads,
itemsKeysDecryptionResults.filter(isDecryptedPayload).map((p) => CreateDecryptedItemFromPayload(p)),
protocolService,
keyParams,
rootKey,
)
extendArray(results, decryptedPayloads)
return results
}

View File

@@ -0,0 +1,282 @@
import {
AnyKeyParamsContent,
compareVersions,
ContentType,
leftVersionGreaterThanOrEqualToRight,
ProtocolVersion,
} from '@standardnotes/common'
import {
BackupFileType,
CreateAnyKeyParams,
isItemsKey,
isKeySystemItemsKey,
SNItemsKey,
SplitPayloadsByEncryptionType,
} from '@standardnotes/encryption'
import {
ContentTypeUsesKeySystemRootKeyEncryption,
ContentTypeUsesRootKeyEncryption,
BackupFile,
CreateDecryptedItemFromPayload,
CreatePayloadSplit,
DecryptedPayload,
DecryptedPayloadInterface,
EncryptedPayload,
EncryptedPayloadInterface,
isDecryptedPayload,
isDecryptedTransferPayload,
isEncryptedPayload,
isEncryptedTransferPayload,
ItemsKeyContent,
ItemsKeyInterface,
PayloadInterface,
KeySystemItemsKeyInterface,
RootKeyInterface,
KeySystemRootKeyInterface,
isKeySystemRootKey,
RootKeyParamsInterface,
} from '@standardnotes/models'
import { ClientDisplayableError } from '@standardnotes/responses'
import { extendArray } from '@standardnotes/utils'
import { EncryptionService } from './EncryptionService'
export class DecryptBackupFileUseCase {
constructor(private encryption: EncryptionService) {}
async execute(
file: BackupFile,
password?: string,
): Promise<ClientDisplayableError | (EncryptedPayloadInterface | DecryptedPayloadInterface)[]> {
const payloads: (EncryptedPayloadInterface | DecryptedPayloadInterface)[] = file.items.map((item) => {
if (isEncryptedTransferPayload(item)) {
return new EncryptedPayload(item)
} else if (isDecryptedTransferPayload(item)) {
return new DecryptedPayload(item)
} else {
throw Error('Unhandled case in decryptBackupFile')
}
})
const { encrypted, decrypted } = CreatePayloadSplit(payloads)
const type = this.getBackupFileType(file, payloads)
switch (type) {
case BackupFileType.Corrupt:
return new ClientDisplayableError('Invalid backup file.')
case BackupFileType.Encrypted: {
if (!password) {
throw Error('Attempting to decrypt encrypted file with no password')
}
const keyParamsData = (file.keyParams || file.auth_params) as AnyKeyParamsContent
const rootKey = await this.encryption.computeRootKey(password, CreateAnyKeyParams(keyParamsData))
const results = await this.decryptEncrypted({
password,
payloads: encrypted,
rootKey,
keyParams: CreateAnyKeyParams(keyParamsData),
})
return [...decrypted, ...results]
}
case BackupFileType.EncryptedWithNonEncryptedItemsKey:
return [...decrypted, ...(await this.decryptEncryptedWithNonEncryptedItemsKey(payloads))]
case BackupFileType.FullyDecrypted:
return [...decrypted, ...encrypted]
}
}
private getBackupFileType(file: BackupFile, payloads: PayloadInterface[]): BackupFileType {
if (file.keyParams || file.auth_params) {
return BackupFileType.Encrypted
} else {
const hasEncryptedItem = payloads.find(isEncryptedPayload)
const hasDecryptedItemsKey = payloads.find(
(payload) => payload.content_type === ContentType.ItemsKey && isDecryptedPayload(payload),
)
if (hasEncryptedItem && hasDecryptedItemsKey) {
return BackupFileType.EncryptedWithNonEncryptedItemsKey
} else if (!hasEncryptedItem) {
return BackupFileType.FullyDecrypted
} else {
return BackupFileType.Corrupt
}
}
}
private async decryptEncrypted(dto: {
password: string
keyParams: RootKeyParamsInterface
payloads: EncryptedPayloadInterface[]
rootKey: RootKeyInterface
}): Promise<(EncryptedPayloadInterface | DecryptedPayloadInterface)[]> {
const results: (EncryptedPayloadInterface | DecryptedPayloadInterface)[] = []
const { rootKeyEncryption, itemsKeyEncryption } = SplitPayloadsByEncryptionType(dto.payloads)
const rootKeyBasedDecryptionResults = await this.encryption.decryptSplit({
usesRootKey: {
items: rootKeyEncryption || [],
key: dto.rootKey,
},
})
extendArray(results, rootKeyBasedDecryptionResults)
const decryptedPayloads = await this.decrypt({
payloads: itemsKeyEncryption || [],
availableItemsKeys: rootKeyBasedDecryptionResults
.filter(isItemsKey)
.filter(isDecryptedPayload)
.map((p) => CreateDecryptedItemFromPayload(p)),
keyParams: dto.keyParams,
rootKey: dto.rootKey,
})
extendArray(results, decryptedPayloads)
return results
}
private async decryptEncryptedWithNonEncryptedItemsKey(
payloads: (EncryptedPayloadInterface | DecryptedPayloadInterface)[],
): Promise<(EncryptedPayloadInterface | DecryptedPayloadInterface)[]> {
const decryptedItemsKeys: DecryptedPayloadInterface<ItemsKeyContent>[] = []
const encryptedPayloads: EncryptedPayloadInterface[] = []
payloads.forEach((payload) => {
if (payload.content_type === ContentType.ItemsKey && isDecryptedPayload(payload)) {
decryptedItemsKeys.push(payload as DecryptedPayloadInterface<ItemsKeyContent>)
} else if (isEncryptedPayload(payload)) {
encryptedPayloads.push(payload)
}
})
const itemsKeys = decryptedItemsKeys.map((p) => CreateDecryptedItemFromPayload<ItemsKeyContent, SNItemsKey>(p))
return this.decrypt({ payloads: encryptedPayloads, availableItemsKeys: itemsKeys, rootKey: undefined })
}
private findKeyToUseForPayload(dto: {
payload: EncryptedPayloadInterface
availableKeys: ItemsKeyInterface[]
keyParams?: RootKeyParamsInterface
rootKey?: RootKeyInterface
}): ItemsKeyInterface | RootKeyInterface | KeySystemRootKeyInterface | KeySystemItemsKeyInterface | undefined {
if (ContentTypeUsesRootKeyEncryption(dto.payload.content_type)) {
if (!dto.rootKey) {
throw new Error('Attempting to decrypt root key encrypted payload with no root key')
}
return dto.rootKey
}
if (ContentTypeUsesKeySystemRootKeyEncryption(dto.payload.content_type)) {
throw new Error('Backup file key system root key encryption is not supported')
}
let itemsKey: ItemsKeyInterface | RootKeyInterface | KeySystemItemsKeyInterface | undefined
if (dto.payload.items_key_id) {
itemsKey = this.encryption.itemsKeyForEncryptedPayload(dto.payload)
if (itemsKey) {
return itemsKey
}
}
itemsKey = dto.availableKeys.find((itemsKeyPayload) => {
return dto.payload.items_key_id === itemsKeyPayload.uuid
})
if (itemsKey) {
return itemsKey
}
if (!dto.keyParams) {
return undefined
}
const payloadVersion = dto.payload.version as ProtocolVersion
/**
* Payloads with versions <= 003 use root key directly for encryption.
* However, if the incoming key params are >= 004, this means we should
* have an items key based off the 003 root key. We can't use the 004
* root key directly because it's missing dataAuthenticationKey.
*/
if (leftVersionGreaterThanOrEqualToRight(dto.keyParams.version, ProtocolVersion.V004)) {
itemsKey = this.encryption.defaultItemsKeyForItemVersion(payloadVersion, dto.availableKeys)
} else if (compareVersions(payloadVersion, ProtocolVersion.V003) <= 0) {
itemsKey = dto.rootKey
}
return itemsKey
}
private async decrypt(dto: {
payloads: EncryptedPayloadInterface[]
availableItemsKeys: ItemsKeyInterface[]
rootKey: RootKeyInterface | undefined
keyParams?: RootKeyParamsInterface
}): Promise<(DecryptedPayloadInterface | EncryptedPayloadInterface)[]> {
const results: (DecryptedPayloadInterface | EncryptedPayloadInterface)[] = []
for (const encryptedPayload of dto.payloads) {
try {
const key = this.findKeyToUseForPayload({
payload: encryptedPayload,
availableKeys: dto.availableItemsKeys,
keyParams: dto.keyParams,
rootKey: dto.rootKey,
})
if (!key) {
results.push(
encryptedPayload.copy({
errorDecrypting: true,
}),
)
continue
}
if (isItemsKey(key) || isKeySystemItemsKey(key)) {
const decryptedPayload = await this.encryption.decryptSplitSingle({
usesItemsKey: {
items: [encryptedPayload],
key: key,
},
})
results.push(decryptedPayload)
} else if (isKeySystemRootKey(key)) {
const decryptedPayload = await this.encryption.decryptSplitSingle({
usesKeySystemRootKey: {
items: [encryptedPayload],
key: key,
},
})
results.push(decryptedPayload)
} else {
const decryptedPayload = await this.encryption.decryptSplitSingle({
usesRootKey: {
items: [encryptedPayload],
key: key,
},
})
results.push(decryptedPayload)
}
} catch (e) {
results.push(
encryptedPayload.copy({
errorDecrypting: true,
}),
)
console.error('Error decrypting payload', encryptedPayload, e)
}
}
return results
}
}

View File

@@ -1,9 +1,8 @@
import { MutatorClientInterface } from './../Mutator/MutatorClientInterface'
import {
CreateAnyKeyParams,
CreateEncryptionSplitWithKeyLookup,
DecryptedParameters,
EncryptedParameters,
encryptedParametersFromPayload,
encryptedInputParametersFromPayload,
EncryptionProviderInterface,
ErrorDecryptingParameters,
findDefaultItemsKey,
@@ -23,6 +22,11 @@ import {
SplitPayloadsByEncryptionType,
V001Algorithm,
V002Algorithm,
PublicKeySet,
EncryptedOutputParameters,
KeySystemKeyManagerInterface,
AsymmetricSignatureVerificationDetachedResult,
AsymmetricallyEncryptedString,
} from '@standardnotes/encryption'
import {
BackupFile,
@@ -37,9 +41,15 @@ import {
ItemContent,
ItemsKeyInterface,
RootKeyInterface,
KeySystemItemsKeyInterface,
KeySystemIdentifier,
AsymmetricMessagePayload,
KeySystemRootKeyInterface,
KeySystemRootKeyParamsInterface,
TrustedContactInterface,
} from '@standardnotes/models'
import { ClientDisplayableError } from '@standardnotes/responses'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { PkcKeyPair, PureCryptoInterface } from '@standardnotes/sncrypto-common'
import {
extendArray,
isNotUndefined,
@@ -68,10 +78,10 @@ import { DeviceInterface } from '../Device/DeviceInterface'
import { StorageServiceInterface } from '../Storage/StorageServiceInterface'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { SyncEvent } from '../Event/SyncEvent'
import { DiagnosticInfo } from '../Diagnostics/ServiceDiagnostics'
import { RootKeyEncryptionService } from './RootKeyEncryption'
import { DecryptBackupFile } from './BackupFileDecryptor'
import { DecryptBackupFileUseCase } from './DecryptBackupFileUseCase'
import { EncryptionServiceEvent } from './EncryptionServiceEvent'
import { DecryptedParameters } from '@standardnotes/encryption/src/Domain/Types/DecryptedParameters'
/**
* The encryption service is responsible for the encryption and decryption of payloads, and
@@ -108,9 +118,11 @@ export class EncryptionService extends AbstractService<EncryptionServiceEvent> i
constructor(
private itemManager: ItemManagerInterface,
private mutator: MutatorClientInterface,
private payloadManager: PayloadManagerInterface,
public deviceInterface: DeviceInterface,
private storageService: StorageServiceInterface,
public readonly keys: KeySystemKeyManagerInterface,
private identifier: ApplicationIdentifier,
public crypto: PureCryptoInterface,
protected override internalEventBus: InternalEventBusInterface,
@@ -125,17 +137,22 @@ export class EncryptionService extends AbstractService<EncryptionServiceEvent> i
payloadManager,
storageService,
this.operatorManager,
keys,
internalEventBus,
)
this.rootKeyEncryption = new RootKeyEncryptionService(
this.itemManager,
this.mutator,
this.operatorManager,
this.deviceInterface,
this.storageService,
this.payloadManager,
keys,
this.identifier,
this.internalEventBus,
)
this.rootKeyObserverDisposer = this.rootKeyEncryption.addEventObserver((event) => {
this.itemsEncryption.userVersion = this.getUserVersion()
if (event === RootKeyServiceEvent.RootKeyStatusChanged) {
@@ -166,6 +183,32 @@ export class EncryptionService extends AbstractService<EncryptionServiceEvent> i
super.deinit()
}
/** @throws */
getKeyPair(): PkcKeyPair {
const rootKey = this.getRootKey()
if (!rootKey?.encryptionKeyPair) {
throw new Error('Account keypair not found')
}
return rootKey.encryptionKeyPair
}
/** @throws */
getSigningKeyPair(): PkcKeyPair {
const rootKey = this.getRootKey()
if (!rootKey?.signingKeyPair) {
throw new Error('Account keypair not found')
}
return rootKey.signingKeyPair
}
hasSigningKeyPair(): boolean {
return !!this.getRootKey()?.signingKeyPair
}
public async initialize() {
await this.rootKeyEncryption.initialize()
}
@@ -213,8 +256,12 @@ export class EncryptionService extends AbstractService<EncryptionServiceEvent> i
return this.itemsEncryption.repersistAllItems()
}
public async reencryptItemsKeys(): Promise<void> {
await this.rootKeyEncryption.reencryptItemsKeys()
public async reencryptApplicableItemsAfterUserRootKeyChange(): Promise<void> {
await this.rootKeyEncryption.reencryptApplicableItemsAfterUserRootKeyChange()
}
public reencryptKeySystemItemsKeysForVault(keySystemIdentifier: KeySystemIdentifier): Promise<void> {
return this.rootKeyEncryption.reencryptKeySystemItemsKeysForVault(keySystemIdentifier)
}
public async createNewItemsKeyWithRollback(): Promise<() => Promise<void>> {
@@ -222,11 +269,14 @@ export class EncryptionService extends AbstractService<EncryptionServiceEvent> i
}
public async decryptErroredPayloads(): Promise<void> {
await this.itemsEncryption.decryptErroredPayloads()
await this.rootKeyEncryption.decryptErroredRootPayloads()
await this.itemsEncryption.decryptErroredItemPayloads()
}
public itemsKeyForPayload(payload: EncryptedPayloadInterface): ItemsKeyInterface | undefined {
return this.itemsEncryption.itemsKeyForPayload(payload)
public itemsKeyForEncryptedPayload(
payload: EncryptedPayloadInterface,
): ItemsKeyInterface | KeySystemItemsKeyInterface | undefined {
return this.itemsEncryption.itemsKeyForEncryptedPayload(payload)
}
public defaultItemsKeyForItemVersion(
@@ -241,34 +291,66 @@ export class EncryptionService extends AbstractService<EncryptionServiceEvent> i
}
public async encryptSplit(split: KeyedEncryptionSplit): Promise<EncryptedPayloadInterface[]> {
const allEncryptedParams: EncryptedParameters[] = []
const allEncryptedParams: EncryptedOutputParameters[] = []
if (split.usesRootKey) {
const {
usesRootKey,
usesItemsKey,
usesKeySystemRootKey,
usesRootKeyWithKeyLookup,
usesItemsKeyWithKeyLookup,
usesKeySystemRootKeyWithKeyLookup,
} = split
const signingKeyPair = this.hasSigningKeyPair() ? this.getSigningKeyPair() : undefined
if (usesRootKey) {
const rootKeyEncrypted = await this.rootKeyEncryption.encryptPayloads(
split.usesRootKey.items,
split.usesRootKey.key,
usesRootKey.items,
usesRootKey.key,
signingKeyPair,
)
extendArray(allEncryptedParams, rootKeyEncrypted)
}
if (split.usesItemsKey) {
if (usesRootKeyWithKeyLookup) {
const rootKeyEncrypted = await this.rootKeyEncryption.encryptPayloadsWithKeyLookup(
usesRootKeyWithKeyLookup.items,
signingKeyPair,
)
extendArray(allEncryptedParams, rootKeyEncrypted)
}
if (usesKeySystemRootKey) {
const keySystemRootKeyEncrypted = await this.rootKeyEncryption.encryptPayloads(
usesKeySystemRootKey.items,
usesKeySystemRootKey.key,
signingKeyPair,
)
extendArray(allEncryptedParams, keySystemRootKeyEncrypted)
}
if (usesKeySystemRootKeyWithKeyLookup) {
const keySystemRootKeyEncrypted = await this.rootKeyEncryption.encryptPayloadsWithKeyLookup(
usesKeySystemRootKeyWithKeyLookup.items,
signingKeyPair,
)
extendArray(allEncryptedParams, keySystemRootKeyEncrypted)
}
if (usesItemsKey) {
const itemsKeyEncrypted = await this.itemsEncryption.encryptPayloads(
split.usesItemsKey.items,
split.usesItemsKey.key,
usesItemsKey.items,
usesItemsKey.key,
signingKeyPair,
)
extendArray(allEncryptedParams, itemsKeyEncrypted)
}
if (split.usesRootKeyWithKeyLookup) {
const rootKeyEncrypted = await this.rootKeyEncryption.encryptPayloadsWithKeyLookup(
split.usesRootKeyWithKeyLookup.items,
)
extendArray(allEncryptedParams, rootKeyEncrypted)
}
if (split.usesItemsKeyWithKeyLookup) {
if (usesItemsKeyWithKeyLookup) {
const itemsKeyEncrypted = await this.itemsEncryption.encryptPayloadsWithKeyLookup(
split.usesItemsKeyWithKeyLookup.items,
usesItemsKeyWithKeyLookup.items,
signingKeyPair,
)
extendArray(allEncryptedParams, itemsKeyEncrypted)
}
@@ -300,32 +382,48 @@ export class EncryptionService extends AbstractService<EncryptionServiceEvent> i
>(split: KeyedDecryptionSplit): Promise<(P | EncryptedPayloadInterface)[]> {
const resultParams: (DecryptedParameters<C> | ErrorDecryptingParameters)[] = []
if (split.usesRootKey) {
const rootKeyDecrypted = await this.rootKeyEncryption.decryptPayloads<C>(
split.usesRootKey.items,
split.usesRootKey.key,
)
const {
usesRootKey,
usesItemsKey,
usesKeySystemRootKey,
usesRootKeyWithKeyLookup,
usesItemsKeyWithKeyLookup,
usesKeySystemRootKeyWithKeyLookup,
} = split
if (usesRootKey) {
const rootKeyDecrypted = await this.rootKeyEncryption.decryptPayloads<C>(usesRootKey.items, usesRootKey.key)
extendArray(resultParams, rootKeyDecrypted)
}
if (split.usesRootKeyWithKeyLookup) {
if (usesRootKeyWithKeyLookup) {
const rootKeyDecrypted = await this.rootKeyEncryption.decryptPayloadsWithKeyLookup<C>(
split.usesRootKeyWithKeyLookup.items,
usesRootKeyWithKeyLookup.items,
)
extendArray(resultParams, rootKeyDecrypted)
}
if (split.usesItemsKey) {
const itemsKeyDecrypted = await this.itemsEncryption.decryptPayloads<C>(
split.usesItemsKey.items,
split.usesItemsKey.key,
if (usesKeySystemRootKey) {
const keySystemRootKeyDecrypted = await this.rootKeyEncryption.decryptPayloads<C>(
usesKeySystemRootKey.items,
usesKeySystemRootKey.key,
)
extendArray(resultParams, keySystemRootKeyDecrypted)
}
if (usesKeySystemRootKeyWithKeyLookup) {
const keySystemRootKeyDecrypted = await this.rootKeyEncryption.decryptPayloadsWithKeyLookup<C>(
usesKeySystemRootKeyWithKeyLookup.items,
)
extendArray(resultParams, keySystemRootKeyDecrypted)
}
if (usesItemsKey) {
const itemsKeyDecrypted = await this.itemsEncryption.decryptPayloads<C>(usesItemsKey.items, usesItemsKey.key)
extendArray(resultParams, itemsKeyDecrypted)
}
if (split.usesItemsKeyWithKeyLookup) {
if (usesItemsKeyWithKeyLookup) {
const itemsKeyDecrypted = await this.itemsEncryption.decryptPayloadsWithKeyLookup<C>(
split.usesItemsKeyWithKeyLookup.items,
usesItemsKeyWithKeyLookup.items,
)
extendArray(resultParams, itemsKeyDecrypted)
}
@@ -349,6 +447,36 @@ export class EncryptionService extends AbstractService<EncryptionServiceEvent> i
return packagedResults
}
async decryptPayloadWithKeyLookup<
C extends ItemContent = ItemContent,
P extends DecryptedPayloadInterface<C> = DecryptedPayloadInterface<C>,
>(
payload: EncryptedPayloadInterface,
): Promise<{
parameters: DecryptedParameters<C> | ErrorDecryptingParameters
payload: P | EncryptedPayloadInterface
}> {
const decryptedParameters = await this.itemsEncryption.decryptPayloadWithKeyLookup<C>(payload)
if (isErrorDecryptingParameters(decryptedParameters)) {
return {
parameters: decryptedParameters,
payload: new EncryptedPayload({
...payload.ejected(),
...decryptedParameters,
}),
}
} else {
return {
parameters: decryptedParameters,
payload: new DecryptedPayload<C>({
...payload.ejected(),
...decryptedParameters,
}) as P,
}
}
}
/**
* Returns true if the user's account protocol version is not equal to the latest version.
*/
@@ -420,27 +548,130 @@ export class EncryptionService extends AbstractService<EncryptionServiceEvent> i
* Computes a root key given a password and key params.
* Delegates computation to respective protocol operator.
*/
public async computeRootKey(password: string, keyParams: SNRootKeyParams): Promise<RootKeyInterface> {
public async computeRootKey<K extends RootKeyInterface>(password: string, keyParams: SNRootKeyParams): Promise<K> {
return this.rootKeyEncryption.computeRootKey(password, keyParams)
}
/**
* Creates a root key using the latest protocol version
*/
public async createRootKey(
public async createRootKey<K extends RootKeyInterface>(
identifier: string,
password: string,
origination: KeyParamsOrigination,
version?: ProtocolVersion,
) {
): Promise<K> {
return this.rootKeyEncryption.createRootKey(identifier, password, origination, version)
}
createRandomizedKeySystemRootKey(dto: {
systemIdentifier: KeySystemIdentifier
systemName: string
systemDescription?: string
}): KeySystemRootKeyInterface {
return this.operatorManager.defaultOperator().createRandomizedKeySystemRootKey(dto)
}
createUserInputtedKeySystemRootKey(dto: {
systemIdentifier: KeySystemIdentifier
systemName: string
systemDescription?: string
userInputtedPassword: string
}): KeySystemRootKeyInterface {
return this.operatorManager.defaultOperator().createUserInputtedKeySystemRootKey(dto)
}
deriveUserInputtedKeySystemRootKey(dto: {
keyParams: KeySystemRootKeyParamsInterface
userInputtedPassword: string
}): KeySystemRootKeyInterface {
return this.operatorManager.defaultOperator().deriveUserInputtedKeySystemRootKey(dto)
}
createKeySystemItemsKey(
uuid: string,
keySystemIdentifier: KeySystemIdentifier,
sharedVaultUuid: string | undefined,
rootKeyToken: string,
): KeySystemItemsKeyInterface {
return this.operatorManager
.defaultOperator()
.createKeySystemItemsKey(uuid, keySystemIdentifier, sharedVaultUuid, rootKeyToken)
}
asymmetricallyEncryptMessage(dto: {
message: AsymmetricMessagePayload
senderKeyPair: PkcKeyPair
senderSigningKeyPair: PkcKeyPair
recipientPublicKey: string
}): AsymmetricallyEncryptedString {
const operator = this.operatorManager.defaultOperator()
const encrypted = operator.asymmetricEncrypt({
stringToEncrypt: JSON.stringify(dto.message),
senderKeyPair: dto.senderKeyPair,
senderSigningKeyPair: dto.senderSigningKeyPair,
recipientPublicKey: dto.recipientPublicKey,
})
return encrypted
}
asymmetricallyDecryptMessage<M extends AsymmetricMessagePayload>(dto: {
encryptedString: AsymmetricallyEncryptedString
trustedSender: TrustedContactInterface | undefined
privateKey: string
}): M | undefined {
const defaultOperator = this.operatorManager.defaultOperator()
const version = defaultOperator.versionForAsymmetricallyEncryptedString(dto.encryptedString)
const keyOperator = this.operatorManager.operatorForVersion(version)
const decryptedResult = keyOperator.asymmetricDecrypt({
stringToDecrypt: dto.encryptedString,
recipientSecretKey: dto.privateKey,
})
if (!decryptedResult) {
return undefined
}
if (!decryptedResult.signatureVerified) {
return undefined
}
if (dto.trustedSender) {
if (!dto.trustedSender.isPublicKeyTrusted(decryptedResult.senderPublicKey)) {
return undefined
}
if (!dto.trustedSender.isSigningKeyTrusted(decryptedResult.signaturePublicKey)) {
return undefined
}
}
return JSON.parse(decryptedResult.plaintext)
}
asymmetricSignatureVerifyDetached(
encryptedString: AsymmetricallyEncryptedString,
): AsymmetricSignatureVerificationDetachedResult {
const defaultOperator = this.operatorManager.defaultOperator()
const version = defaultOperator.versionForAsymmetricallyEncryptedString(encryptedString)
const keyOperator = this.operatorManager.operatorForVersion(version)
return keyOperator.asymmetricSignatureVerifyDetached(encryptedString)
}
getSenderPublicKeySetFromAsymmetricallyEncryptedString(string: AsymmetricallyEncryptedString): PublicKeySet {
const defaultOperator = this.operatorManager.defaultOperator()
const version = defaultOperator.versionForAsymmetricallyEncryptedString(string)
const keyOperator = this.operatorManager.operatorForVersion(version)
return keyOperator.getSenderPublicKeySetFromAsymmetricallyEncryptedString(string)
}
public async decryptBackupFile(
file: BackupFile,
password?: string,
): Promise<ClientDisplayableError | (EncryptedPayloadInterface | DecryptedPayloadInterface<ItemContent>)[]> {
const result = await DecryptBackupFile(file, this, password)
const usecase = new DecryptBackupFileUseCase(this)
const result = await usecase.execute(file, password)
return result
}
@@ -468,7 +699,7 @@ export class EncryptionService extends AbstractService<EncryptionServiceEvent> i
items: ejected,
}
const keyParams = await this.getRootKeyParams()
const keyParams = this.getRootKeyParams()
data.keyParams = keyParams?.getPortableValue()
return data
}
@@ -504,7 +735,7 @@ export class EncryptionService extends AbstractService<EncryptionServiceEvent> i
return (await this.rootKeyEncryption.hasRootKeyWrapper()) && this.rootKeyEncryption.getRootKey() == undefined
}
public async getRootKeyParams() {
public getRootKeyParams() {
return this.rootKeyEncryption.getRootKeyParams()
}
@@ -517,7 +748,7 @@ export class EncryptionService extends AbstractService<EncryptionServiceEvent> i
* Wrapping key params are read from disk.
*/
public async computeWrappingKey(passcode: string) {
const keyParams = await this.rootKeyEncryption.getSureRootKeyWrapperKeyParams()
const keyParams = this.rootKeyEncryption.getSureRootKeyWrapperKeyParams()
const key = await this.computeRootKey(passcode, keyParams)
return key
}
@@ -545,17 +776,21 @@ export class EncryptionService extends AbstractService<EncryptionServiceEvent> i
await this.rootKeyEncryption.removeRootKeyWrapper()
}
public async setRootKey(key: SNRootKey, wrappingKey?: SNRootKey) {
public async setRootKey(key: RootKeyInterface, wrappingKey?: SNRootKey) {
await this.rootKeyEncryption.setRootKey(key, wrappingKey)
}
/**
* Returns the in-memory root key value.
*/
public getRootKey() {
public getRootKey(): RootKeyInterface | undefined {
return this.rootKeyEncryption.getRootKey()
}
public getSureRootKey(): RootKeyInterface {
return this.rootKeyEncryption.getRootKey() as RootKeyInterface
}
/**
* Deletes root key and wrapper from keychain. Used when signing out of application.
*/
@@ -571,26 +806,31 @@ export class EncryptionService extends AbstractService<EncryptionServiceEvent> i
return this.rootKeyEncryption.validatePasscode(passcode)
}
public getEmbeddedPayloadAuthenticatedData(
public getEmbeddedPayloadAuthenticatedData<D extends ItemAuthenticatedData>(
payload: EncryptedPayloadInterface,
): RootKeyEncryptedAuthenticatedData | ItemAuthenticatedData | LegacyAttachedData | undefined {
): D | undefined {
const version = payload.version
if (!version) {
return undefined
}
const operator = this.operatorManager.operatorForVersion(version)
const authenticatedData = operator.getPayloadAuthenticatedData(encryptedParametersFromPayload(payload))
return authenticatedData
const authenticatedData = operator.getPayloadAuthenticatedDataForExternalUse(
encryptedInputParametersFromPayload(payload),
)
return authenticatedData as D
}
/** Returns the key params attached to this key's encrypted payload */
public getKeyEmbeddedKeyParams(key: EncryptedPayloadInterface): SNRootKeyParams | undefined {
public getKeyEmbeddedKeyParamsFromItemsKey(key: EncryptedPayloadInterface): SNRootKeyParams | undefined {
const authenticatedData = this.getEmbeddedPayloadAuthenticatedData(key)
if (!authenticatedData) {
return undefined
}
if (isVersionLessThanOrEqualTo(key.version, ProtocolVersion.V003)) {
const rawKeyParams = authenticatedData as LegacyAttachedData
const rawKeyParams = authenticatedData as unknown as LegacyAttachedData
return this.createKeyParams(rawKeyParams)
} else {
const rawKeyParams = (authenticatedData as RootKeyEncryptedAuthenticatedData).kp
@@ -683,7 +923,7 @@ export class EncryptionService extends AbstractService<EncryptionServiceEvent> i
const hasSyncedItemsKey = !isNullOrUndefined(defaultSyncedKey)
if (hasSyncedItemsKey) {
/** Delete all never synced keys */
await this.itemManager.setItemsToBeDeleted(neverSyncedKeys)
await this.mutator.setItemsToBeDeleted(neverSyncedKeys)
} else {
/**
* No previous synced items key.
@@ -692,14 +932,14 @@ export class EncryptionService extends AbstractService<EncryptionServiceEvent> i
* we end up with 0 items keys, create a new one. This covers the case when you open
* the app offline and it creates an 004 key, and then you sign into an 003 account.
*/
const rootKeyParams = await this.getRootKeyParams()
const rootKeyParams = this.getRootKeyParams()
if (rootKeyParams) {
/** If neverSynced.version != rootKey.version, delete. */
const toDelete = neverSyncedKeys.filter((itemsKey) => {
return itemsKey.keyVersion !== rootKeyParams.version
})
if (toDelete.length > 0) {
await this.itemManager.setItemsToBeDeleted(toDelete)
await this.mutator.setItemsToBeDeleted(toDelete)
}
if (this.itemsEncryption.getItemsKeys().length === 0) {
@@ -741,26 +981,7 @@ export class EncryptionService extends AbstractService<EncryptionServiceEvent> i
const unsyncedKeys = this.itemsEncryption.getItemsKeys().filter((key) => key.neverSynced && !key.dirty)
if (unsyncedKeys.length > 0) {
void this.itemManager.setItemsDirty(unsyncedKeys)
}
}
override async getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return {
encryption: {
getLatestVersion: this.getLatestVersion(),
hasAccount: this.hasAccount(),
hasRootKeyEncryptionSource: this.hasRootKeyEncryptionSource(),
getUserVersion: this.getUserVersion(),
upgradeAvailable: await this.upgradeAvailable(),
accountUpgradeAvailable: this.accountUpgradeAvailable(),
passcodeUpgradeAvailable: await this.passcodeUpgradeAvailable(),
hasPasscode: this.hasPasscode(),
isPasscodeLocked: await this.isPasscodeLocked(),
needsNewRootKeyBasedItemsKey: this.needsNewRootKeyBasedItemsKey(),
...(await this.itemsEncryption.getDiagnostics()),
...(await this.rootKeyEncryption.getDiagnostics()),
},
void this.mutator.setItemsDirty(unsyncedKeys)
}
}
}

View File

@@ -49,7 +49,7 @@ export async function DecryptItemsKeyByPromptingUser(
| 'aborted'
> {
if (!keyParams) {
keyParams = encryptor.getKeyEmbeddedKeyParams(itemsKey)
keyParams = encryptor.getKeyEmbeddedKeyParamsFromItemsKey(itemsKey)
}
if (!keyParams) {

View File

@@ -1,7 +1,6 @@
import { ContentType, ProtocolVersion } from '@standardnotes/common'
import {
DecryptedParameters,
EncryptedParameters,
ErrorDecryptingParameters,
findDefaultItemsKey,
isErrorDecryptingParameters,
@@ -9,26 +8,30 @@ import {
StandardException,
encryptPayload,
decryptPayload,
EncryptedOutputParameters,
KeySystemKeyManagerInterface,
} from '@standardnotes/encryption'
import {
ContentTypeUsesKeySystemRootKeyEncryption,
DecryptedPayload,
DecryptedPayloadInterface,
EncryptedPayload,
EncryptedPayloadInterface,
KeySystemRootKeyInterface,
isEncryptedPayload,
ItemContent,
ItemsKeyInterface,
PayloadEmitSource,
KeySystemItemsKeyInterface,
SureFindPayload,
ContentTypeUsesRootKeyEncryption,
} from '@standardnotes/models'
import { Uuids } from '@standardnotes/utils'
import { DiagnosticInfo } from '../Diagnostics/ServiceDiagnostics'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
import { PayloadManagerInterface } from '../Payloads/PayloadManagerInterface'
import { AbstractService } from '../Service/AbstractService'
import { StorageServiceInterface } from '../Storage/StorageServiceInterface'
import { PkcKeyPair } from '@standardnotes/sncrypto-common'
export class ItemsEncryptionService extends AbstractService {
private removeItemsObserver!: () => void
@@ -39,13 +42,14 @@ export class ItemsEncryptionService extends AbstractService {
private payloadManager: PayloadManagerInterface,
private storageService: StorageServiceInterface,
private operatorManager: OperatorManager,
private keys: KeySystemKeyManagerInterface,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
this.removeItemsObserver = this.itemManager.addObserver([ContentType.ItemsKey], ({ changed, inserted }) => {
if (changed.concat(inserted).length > 0) {
void this.decryptErroredPayloads()
void this.decryptErroredItemPayloads()
}
})
}
@@ -54,6 +58,8 @@ export class ItemsEncryptionService extends AbstractService {
;(this.itemManager as unknown) = undefined
;(this.payloadManager as unknown) = undefined
;(this.storageService as unknown) = undefined
;(this.operatorManager as unknown) = undefined
;(this.keys as unknown) = undefined
this.removeItemsObserver()
;(this.removeItemsObserver as unknown) = undefined
super.deinit()
@@ -70,12 +76,17 @@ export class ItemsEncryptionService extends AbstractService {
return this.storageService.savePayloads(payloads)
}
public getItemsKeys() {
public getItemsKeys(): ItemsKeyInterface[] {
return this.itemManager.getDisplayableItemsKeys()
}
public itemsKeyForPayload(payload: EncryptedPayloadInterface): ItemsKeyInterface | undefined {
return this.getItemsKeys().find(
public itemsKeyForEncryptedPayload(
payload: EncryptedPayloadInterface,
): ItemsKeyInterface | KeySystemItemsKeyInterface | undefined {
const itemsKeys = this.getItemsKeys()
const keySystemItemsKeys = this.itemManager.getItems<KeySystemItemsKeyInterface>(ContentType.KeySystemItemsKey)
return [...itemsKeys, ...keySystemItemsKeys].find(
(key) => key.uuid === payload.items_key_id || key.duplicateOf === payload.items_key_id,
)
}
@@ -84,8 +95,20 @@ export class ItemsEncryptionService extends AbstractService {
return findDefaultItemsKey(this.getItemsKeys())
}
private keyToUseForItemEncryption(): ItemsKeyInterface | StandardException {
private keyToUseForItemEncryption(
payload: DecryptedPayloadInterface,
): ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface | StandardException {
if (payload.key_system_identifier) {
const keySystemItemsKey = this.keys.getPrimaryKeySystemItemsKey(payload.key_system_identifier)
if (!keySystemItemsKey) {
return new StandardException('Cannot find key system items key to use for encryption')
}
return keySystemItemsKey
}
const defaultKey = this.getDefaultItemsKey()
let result: ItemsKeyInterface | undefined = undefined
if (this.userVersion && this.userVersion !== defaultKey?.keyVersion) {
@@ -107,9 +130,11 @@ export class ItemsEncryptionService extends AbstractService {
return result
}
private keyToUseForDecryptionOfPayload(payload: EncryptedPayloadInterface): ItemsKeyInterface | undefined {
private keyToUseForDecryptionOfPayload(
payload: EncryptedPayloadInterface,
): ItemsKeyInterface | KeySystemItemsKeyInterface | undefined {
if (payload.items_key_id) {
const itemsKey = this.itemsKeyForPayload(payload)
const itemsKey = this.itemsKeyForEncryptedPayload(payload)
return itemsKey
}
@@ -117,20 +142,24 @@ export class ItemsEncryptionService extends AbstractService {
return defaultKey
}
public async encryptPayloadWithKeyLookup(payload: DecryptedPayloadInterface): Promise<EncryptedParameters> {
const key = this.keyToUseForItemEncryption()
public async encryptPayloadWithKeyLookup(
payload: DecryptedPayloadInterface,
signingKeyPair?: PkcKeyPair,
): Promise<EncryptedOutputParameters> {
const key = this.keyToUseForItemEncryption(payload)
if (key instanceof StandardException) {
throw Error(key.message)
}
return this.encryptPayload(payload, key)
return this.encryptPayload(payload, key, signingKeyPair)
}
public async encryptPayload(
payload: DecryptedPayloadInterface,
key: ItemsKeyInterface,
): Promise<EncryptedParameters> {
key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface,
signingKeyPair?: PkcKeyPair,
): Promise<EncryptedOutputParameters> {
if (isEncryptedPayload(payload)) {
throw Error('Attempting to encrypt already encrypted payload.')
}
@@ -141,18 +170,22 @@ export class ItemsEncryptionService extends AbstractService {
throw Error('Attempting to encrypt payload with no UuidGenerator.')
}
return encryptPayload(payload, key, this.operatorManager)
return encryptPayload(payload, key, this.operatorManager, signingKeyPair)
}
public async encryptPayloads(
payloads: DecryptedPayloadInterface[],
key: ItemsKeyInterface,
): Promise<EncryptedParameters[]> {
return Promise.all(payloads.map((payload) => this.encryptPayload(payload, key)))
key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface,
signingKeyPair?: PkcKeyPair,
): Promise<EncryptedOutputParameters[]> {
return Promise.all(payloads.map((payload) => this.encryptPayload(payload, key, signingKeyPair)))
}
public async encryptPayloadsWithKeyLookup(payloads: DecryptedPayloadInterface[]): Promise<EncryptedParameters[]> {
return Promise.all(payloads.map((payload) => this.encryptPayloadWithKeyLookup(payload)))
public async encryptPayloadsWithKeyLookup(
payloads: DecryptedPayloadInterface[],
signingKeyPair?: PkcKeyPair,
): Promise<EncryptedOutputParameters[]> {
return Promise.all(payloads.map((payload) => this.encryptPayloadWithKeyLookup(payload, signingKeyPair)))
}
public async decryptPayloadWithKeyLookup<C extends ItemContent = ItemContent>(
@@ -173,7 +206,7 @@ export class ItemsEncryptionService extends AbstractService {
public async decryptPayload<C extends ItemContent = ItemContent>(
payload: EncryptedPayloadInterface,
key: ItemsKeyInterface,
key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface,
): Promise<DecryptedParameters<C> | ErrorDecryptingParameters> {
if (!payload.content) {
return {
@@ -193,21 +226,24 @@ export class ItemsEncryptionService extends AbstractService {
public async decryptPayloads<C extends ItemContent = ItemContent>(
payloads: EncryptedPayloadInterface[],
key: ItemsKeyInterface,
key: ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface,
): Promise<(DecryptedParameters<C> | ErrorDecryptingParameters)[]> {
return Promise.all(payloads.map((payload) => this.decryptPayload<C>(payload, key)))
}
public async decryptErroredPayloads(): Promise<void> {
const payloads = this.payloadManager.invalidPayloads.filter((i) => i.content_type !== ContentType.ItemsKey)
if (payloads.length === 0) {
public async decryptErroredItemPayloads(): Promise<void> {
const erroredItemPayloads = this.payloadManager.invalidPayloads.filter(
(i) =>
!ContentTypeUsesRootKeyEncryption(i.content_type) && !ContentTypeUsesKeySystemRootKeyEncryption(i.content_type),
)
if (erroredItemPayloads.length === 0) {
return
}
const resultParams = await this.decryptPayloadsWithKeyLookup(payloads)
const resultParams = await this.decryptPayloadsWithKeyLookup(erroredItemPayloads)
const decryptedPayloads = resultParams.map((params) => {
const original = SureFindPayload(payloads, params.uuid)
const original = SureFindPayload(erroredItemPayloads, params.uuid)
if (isErrorDecryptingParameters(params)) {
return new EncryptedPayload({
...original.ejected(),
@@ -247,15 +283,4 @@ export class ItemsEncryptionService extends AbstractService {
return key.keyVersion === version
})
}
override async getDiagnostics(): Promise<DiagnosticInfo | undefined> {
const keyForItems = this.keyToUseForItemEncryption()
return {
itemsEncryption: {
itemsKeysIds: Uuids(this.getItemsKeys()),
defaultItemsKeyId: this.getDefaultItemsKey()?.uuid,
keyToUseForItemEncryptionId: keyForItems instanceof StandardException ? undefined : keyForItems.uuid,
},
}
}
}

View File

@@ -1,3 +1,4 @@
import { MutatorClientInterface } from './../Mutator/MutatorClientInterface'
import {
ApplicationIdentifier,
ProtocolVersionLatest,
@@ -17,15 +18,19 @@ import {
CreateAnyKeyParams,
SNRootKey,
isErrorDecryptingParameters,
EncryptedParameters,
DecryptedParameters,
ErrorDecryptingParameters,
findDefaultItemsKey,
ItemsKeyMutator,
encryptPayload,
decryptPayload,
EncryptedOutputParameters,
DecryptedParameters,
KeySystemKeyManagerInterface,
} from '@standardnotes/encryption'
import {
ContentTypeUsesKeySystemRootKeyEncryption,
ContentTypesUsingRootKeyEncryption,
ContentTypeUsesRootKeyEncryption,
CreateDecryptedItemFromPayload,
DecryptedPayload,
DecryptedPayloadInterface,
@@ -34,25 +39,29 @@ import {
EncryptedPayloadInterface,
EncryptedTransferPayload,
FillItemContentSpecialized,
KeySystemRootKeyInterface,
ItemContent,
ItemsKeyContent,
ItemsKeyContentSpecialized,
ItemsKeyInterface,
NamespacedRootKeyInKeychain,
PayloadEmitSource,
PayloadTimestampDefaults,
RootKeyContent,
RootKeyInterface,
SureFindPayload,
KeySystemIdentifier,
} from '@standardnotes/models'
import { UuidGenerator } from '@standardnotes/utils'
import { DeviceInterface } from '../Device/DeviceInterface'
import { DiagnosticInfo } from '../Diagnostics/ServiceDiagnostics'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
import { AbstractService } from '../Service/AbstractService'
import { StorageKey } from '../Storage/StorageKeys'
import { StorageServiceInterface } from '../Storage/StorageServiceInterface'
import { StorageValueModes } from '../Storage/StorageTypes'
import { PayloadManagerInterface } from '../Payloads/PayloadManagerInterface'
import { PkcKeyPair } from '@standardnotes/sncrypto-common'
export class RootKeyEncryptionService extends AbstractService<RootKeyServiceEvent> {
private rootKey?: RootKeyInterface
@@ -60,10 +69,13 @@ export class RootKeyEncryptionService extends AbstractService<RootKeyServiceEven
public memoizedRootKeyParams?: SNRootKeyParams
constructor(
private itemManager: ItemManagerInterface,
private items: ItemManagerInterface,
private mutator: MutatorClientInterface,
private operatorManager: OperatorManager,
public deviceInterface: DeviceInterface,
private storageService: StorageServiceInterface,
private payloadManager: PayloadManagerInterface,
private keys: KeySystemKeyManagerInterface,
private identifier: ApplicationIdentifier,
protected override internalEventBus: InternalEventBusInterface,
) {
@@ -71,7 +83,13 @@ export class RootKeyEncryptionService extends AbstractService<RootKeyServiceEven
}
public override deinit(): void {
;(this.itemManager as unknown) = undefined
;(this.items as unknown) = undefined
;(this.operatorManager as unknown) = undefined
;(this.deviceInterface as unknown) = undefined
;(this.storageService as unknown) = undefined
;(this.payloadManager as unknown) = undefined
;(this.keys as unknown) = undefined
this.rootKey = undefined
this.memoizedRootKeyParams = undefined
super.deinit()
@@ -144,7 +162,7 @@ export class RootKeyEncryptionService extends AbstractService<RootKeyServiceEven
if (this.hasAccount()) {
return this.getSureUserVersion()
} else if (this.hasPasscode()) {
const passcodeParams = await this.getSureRootKeyWrapperKeyParams()
const passcodeParams = this.getSureRootKeyWrapperKeyParams()
return passcodeParams.version
}
@@ -170,7 +188,7 @@ export class RootKeyEncryptionService extends AbstractService<RootKeyServiceEven
return undefined
}
const keyParams = await this.getSureRootKeyParams()
const keyParams = this.getSureRootKeyParams()
return CreateNewRootKey({
...rawKey,
@@ -193,11 +211,8 @@ export class RootKeyEncryptionService extends AbstractService<RootKeyServiceEven
})
}
public async getRootKeyWrapperKeyParams(): Promise<SNRootKeyParams | undefined> {
const rawKeyParams = await this.storageService.getValue(
StorageKey.RootKeyWrapperKeyParams,
StorageValueModes.Nonwrapped,
)
public getRootKeyWrapperKeyParams(): SNRootKeyParams | undefined {
const rawKeyParams = this.storageService.getValue(StorageKey.RootKeyWrapperKeyParams, StorageValueModes.Nonwrapped)
if (!rawKeyParams) {
return undefined
@@ -206,11 +221,11 @@ export class RootKeyEncryptionService extends AbstractService<RootKeyServiceEven
return CreateAnyKeyParams(rawKeyParams as AnyKeyParamsContent)
}
public async getSureRootKeyWrapperKeyParams() {
return this.getRootKeyWrapperKeyParams() as Promise<SNRootKeyParams>
public getSureRootKeyWrapperKeyParams() {
return this.getRootKeyWrapperKeyParams() as SNRootKeyParams
}
public async getRootKeyParams(): Promise<SNRootKeyParams | undefined> {
public getRootKeyParams(): SNRootKeyParams | undefined {
if (this.keyMode === KeyMode.WrapperOnly) {
return this.getRootKeyWrapperKeyParams()
} else if (this.keyMode === KeyMode.RootKeyOnly || this.keyMode === KeyMode.RootKeyPlusWrapper) {
@@ -222,22 +237,22 @@ export class RootKeyEncryptionService extends AbstractService<RootKeyServiceEven
}
}
public async getSureRootKeyParams(): Promise<SNRootKeyParams> {
return this.getRootKeyParams() as Promise<SNRootKeyParams>
public getSureRootKeyParams(): SNRootKeyParams {
return this.getRootKeyParams() as SNRootKeyParams
}
public async computeRootKey(password: string, keyParams: SNRootKeyParams): Promise<RootKeyInterface> {
public async computeRootKey<K extends RootKeyInterface>(password: string, keyParams: SNRootKeyParams): Promise<K> {
const version = keyParams.version
const operator = this.operatorManager.operatorForVersion(version)
return operator.computeRootKey(password, keyParams)
}
public async createRootKey(
public async createRootKey<K extends RootKeyInterface>(
identifier: string,
password: string,
origination: KeyParamsOrigination,
version?: ProtocolVersion,
) {
): Promise<K> {
const operator = version ? this.operatorManager.operatorForVersion(version) : this.operatorManager.defaultOperator()
return operator.createRootKey(identifier, password, origination)
}
@@ -291,8 +306,8 @@ export class RootKeyEncryptionService extends AbstractService<RootKeyServiceEven
}
}
private async recomputeAccountKeyParams(): Promise<SNRootKeyParams | undefined> {
const rawKeyParams = await this.storageService.getValue(StorageKey.RootKeyParams, StorageValueModes.Nonwrapped)
private recomputeAccountKeyParams(): SNRootKeyParams | undefined {
const rawKeyParams = this.storageService.getValue(StorageKey.RootKeyParams, StorageValueModes.Nonwrapped)
if (!rawKeyParams) {
return
@@ -308,10 +323,12 @@ export class RootKeyEncryptionService extends AbstractService<RootKeyServiceEven
*/
private async wrapAndPersistRootKey(wrappingKey: SNRootKey) {
const rootKey = this.getSureRootKey()
const value: DecryptedTransferPayload = {
...rootKey.payload.ejected(),
content: FillItemContentSpecialized(rootKey.persistableValueWhenWrapping()),
}
const payload = new DecryptedPayload(value)
const wrappedKey = await this.encryptPayload(payload, wrappingKey)
@@ -371,7 +388,7 @@ export class RootKeyEncryptionService extends AbstractService<RootKeyServiceEven
if (this.keyMode === KeyMode.WrapperOnly || this.keyMode === KeyMode.RootKeyPlusWrapper) {
if (this.keyMode === KeyMode.WrapperOnly) {
this.setRootKeyInstance(wrappingKey)
await this.reencryptItemsKeys()
await this.reencryptApplicableItemsAfterUserRootKeyChange()
} else {
await this.wrapAndPersistRootKey(wrappingKey)
}
@@ -487,35 +504,65 @@ export class RootKeyEncryptionService extends AbstractService<RootKeyServiceEven
}
private getItemsKeys() {
return this.itemManager.getDisplayableItemsKeys()
return this.items.getDisplayableItemsKeys()
}
public async encrypPayloadWithKeyLookup(payload: DecryptedPayloadInterface): Promise<EncryptedParameters> {
const key = this.getRootKey()
private async encryptPayloadWithKeyLookup(
payload: DecryptedPayloadInterface,
signingKeyPair?: PkcKeyPair,
): Promise<EncryptedOutputParameters> {
let key: RootKeyInterface | KeySystemRootKeyInterface | undefined
if (ContentTypeUsesKeySystemRootKeyEncryption(payload.content_type)) {
if (!payload.key_system_identifier) {
throw Error(`Key system-encrypted payload ${payload.content_type}is missing a key_system_identifier`)
}
key = this.keys.getPrimaryKeySystemRootKey(payload.key_system_identifier)
} else {
key = this.getRootKey()
}
if (key == undefined) {
throw Error('Attempting root key encryption with no root key')
}
return this.encryptPayload(payload, key)
return this.encryptPayload(payload, key, signingKeyPair)
}
public async encryptPayloadsWithKeyLookup(payloads: DecryptedPayloadInterface[]): Promise<EncryptedParameters[]> {
return Promise.all(payloads.map((payload) => this.encrypPayloadWithKeyLookup(payload)))
public async encryptPayloadsWithKeyLookup(
payloads: DecryptedPayloadInterface[],
signingKeyPair?: PkcKeyPair,
): Promise<EncryptedOutputParameters[]> {
return Promise.all(payloads.map((payload) => this.encryptPayloadWithKeyLookup(payload, signingKeyPair)))
}
public async encryptPayload(payload: DecryptedPayloadInterface, key: RootKeyInterface): Promise<EncryptedParameters> {
return encryptPayload(payload, key, this.operatorManager)
public async encryptPayload(
payload: DecryptedPayloadInterface,
key: RootKeyInterface | KeySystemRootKeyInterface,
signingKeyPair?: PkcKeyPair,
): Promise<EncryptedOutputParameters> {
return encryptPayload(payload, key, this.operatorManager, signingKeyPair)
}
public async encryptPayloads(payloads: DecryptedPayloadInterface[], key: RootKeyInterface) {
return Promise.all(payloads.map((payload) => this.encryptPayload(payload, key)))
public async encryptPayloads(
payloads: DecryptedPayloadInterface[],
key: RootKeyInterface | KeySystemRootKeyInterface,
signingKeyPair?: PkcKeyPair,
) {
return Promise.all(payloads.map((payload) => this.encryptPayload(payload, key, signingKeyPair)))
}
public async decryptPayloadWithKeyLookup<C extends ItemContent = ItemContent>(
payload: EncryptedPayloadInterface,
): Promise<DecryptedParameters<C> | ErrorDecryptingParameters> {
const key = this.getRootKey()
let key: RootKeyInterface | KeySystemRootKeyInterface | undefined
if (ContentTypeUsesKeySystemRootKeyEncryption(payload.content_type)) {
if (!payload.key_system_identifier) {
throw Error('Key system root key encrypted payload is missing key_system_identifier')
}
key = this.keys.getPrimaryKeySystemRootKey(payload.key_system_identifier)
} else {
key = this.getRootKey()
}
if (key == undefined) {
return {
@@ -530,7 +577,7 @@ export class RootKeyEncryptionService extends AbstractService<RootKeyServiceEven
public async decryptPayload<C extends ItemContent = ItemContent>(
payload: EncryptedPayloadInterface,
key: RootKeyInterface,
key: RootKeyInterface | KeySystemRootKeyInterface,
): Promise<DecryptedParameters<C> | ErrorDecryptingParameters> {
return decryptPayload(payload, key, this.operatorManager)
}
@@ -543,25 +590,63 @@ export class RootKeyEncryptionService extends AbstractService<RootKeyServiceEven
public async decryptPayloads<C extends ItemContent = ItemContent>(
payloads: EncryptedPayloadInterface[],
key: RootKeyInterface,
key: RootKeyInterface | KeySystemRootKeyInterface,
): Promise<(DecryptedParameters<C> | ErrorDecryptingParameters)[]> {
return Promise.all(payloads.map((payload) => this.decryptPayload<C>(payload, key)))
}
/**
* When the root key changes (non-null only), we must re-encrypt all items
* keys with this new root key (by simply re-syncing).
*/
public async reencryptItemsKeys(): Promise<void> {
const itemsKeys = this.getItemsKeys()
public async decryptErroredRootPayloads(): Promise<void> {
const erroredRootPayloads = this.payloadManager.invalidPayloads.filter(
(i) =>
ContentTypeUsesRootKeyEncryption(i.content_type) || ContentTypeUsesKeySystemRootKeyEncryption(i.content_type),
)
if (erroredRootPayloads.length === 0) {
return
}
if (itemsKeys.length > 0) {
const resultParams = await this.decryptPayloadsWithKeyLookup(erroredRootPayloads)
const decryptedPayloads = resultParams.map((params) => {
const original = SureFindPayload(erroredRootPayloads, params.uuid)
if (isErrorDecryptingParameters(params)) {
return new EncryptedPayload({
...original.ejected(),
...params,
})
} else {
return new DecryptedPayload({
...original.ejected(),
...params,
})
}
})
await this.payloadManager.emitPayloads(decryptedPayloads, PayloadEmitSource.LocalChanged)
}
/**
* When the root key changes, we must re-encrypt all relevant items with this new root key (by simply re-syncing).
*/
public async reencryptApplicableItemsAfterUserRootKeyChange(): Promise<void> {
const items = this.items.getItems(ContentTypesUsingRootKeyEncryption())
if (items.length > 0) {
/**
* Do not call sync after marking dirty.
* Re-encrypting items keys is called by consumers who have specific flows who
* will sync on their own timing
*/
await this.itemManager.setItemsDirty(itemsKeys)
await this.mutator.setItemsDirty(items)
}
}
/**
* When the key system root key changes, we must re-encrypt all vault items keys
* with this new key system root key (by simply re-syncing).
*/
public async reencryptKeySystemItemsKeysForVault(keySystemIdentifier: KeySystemIdentifier): Promise<void> {
const keySystemItemsKeys = this.keys.getKeySystemItemsKeys(keySystemIdentifier)
if (keySystemItemsKeys.length > 0) {
await this.mutator.setItemsDirty(keySystemItemsKeys)
}
}
@@ -599,14 +684,13 @@ export class RootKeyEncryptionService extends AbstractService<RootKeyServiceEven
})
for (const key of defaultKeys) {
await this.itemManager.changeItemsKey(key, (mutator) => {
await this.mutator.changeItemsKey(key, (mutator) => {
mutator.isDefault = false
})
}
const itemsKey = (await this.itemManager.insertItem(itemTemplate)) as ItemsKeyInterface
await this.itemManager.changeItemsKey(itemsKey, (mutator) => {
const itemsKey = await this.mutator.insertItem<ItemsKeyInterface>(itemTemplate)
await this.mutator.changeItemsKey(itemsKey, (mutator) => {
mutator.isDefault = true
})
@@ -618,10 +702,10 @@ export class RootKeyEncryptionService extends AbstractService<RootKeyServiceEven
const newDefaultItemsKey = await this.createNewDefaultItemsKey()
const rollback = async () => {
await this.itemManager.setItemToBeDeleted(newDefaultItemsKey)
await this.mutator.setItemToBeDeleted(newDefaultItemsKey)
if (currentDefaultItemsKey) {
await this.itemManager.changeItem<ItemsKeyMutator>(currentDefaultItemsKey, (mutator) => {
await this.mutator.changeItem<ItemsKeyMutator>(currentDefaultItemsKey, (mutator) => {
mutator.isDefault = true
})
}
@@ -629,19 +713,4 @@ export class RootKeyEncryptionService extends AbstractService<RootKeyServiceEven
return rollback
}
override async getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return {
rootKeyEncryption: {
hasRootKey: this.rootKey != undefined,
keyMode: KeyMode[this.keyMode],
hasRootKeyWrapper: await this.hasRootKeyWrapper(),
hasAccount: this.hasAccount(),
hasRootKeyEncryptionSource: this.hasRootKeyEncryptionSource(),
hasPasscode: this.hasPasscode(),
getEncryptionSourceVersion: this.hasRootKeyEncryptionSource() && (await this.getEncryptionSourceVersion()),
getUserVersion: this.getUserVersion(),
},
}
}
}

View File

@@ -1,68 +1,79 @@
import { ApplicationStage } from './../Application/ApplicationStage'
export enum ApplicationEvent {
SignedIn = 2,
SignedOut = 3,
SignedIn = 'signed-in',
SignedOut = 'signed-out',
/** When a full, potentially multi-page sync completes */
CompletedFullSync = 5,
CompletedFullSync = 'completed-full-sync',
FailedSync = 6,
HighLatencySync = 7,
EnteredOutOfSync = 8,
ExitedOutOfSync = 9,
FailedSync = 'failed-sync',
HighLatencySync = 'high-latency-sync',
EnteredOutOfSync = 'entered-out-of-sync',
ExitedOutOfSync = 'exited-out-of-sync',
ApplicationStageChanged = 'application-stage-changed',
/**
* The application has finished it `prepareForLaunch` state and is now ready for unlock
* The application has finished its prepareForLaunch state and is now ready for unlock
* Called when the application has initialized and is ready for launch, but before
* the application has been unlocked, if applicable. Use this to do pre-launch
* configuration, but do not attempt to access user data like notes or tags.
*/
Started = 10,
Started = 'started',
/**
* The applicaiton is fully unlocked and ready for i/o
* Called when the application has been fully decrypted and unlocked. Use this to
* to begin streaming data like notes and tags.
*/
Launched = 11,
LocalDataLoaded = 12,
Launched = 'launched',
LocalDataLoaded = 'local-data-loaded',
/**
* When the root key or root key wrapper changes. Includes events like account state
* changes (registering, signing in, changing pw, logging out) and passcode state
* changes (adding, removing, changing).
*/
KeyStatusChanged = 13,
KeyStatusChanged = 'key-status-changed',
MajorDataChange = 14,
CompletedRestart = 15,
LocalDataIncrementalLoad = 16,
SyncStatusChanged = 17,
WillSync = 18,
InvalidSyncSession = 19,
LocalDatabaseReadError = 20,
LocalDatabaseWriteError = 21,
MajorDataChange = 'major-data-change',
CompletedRestart = 'completed-restart',
LocalDataIncrementalLoad = 'local-data-incremental-load',
SyncStatusChanged = 'sync-status-changed',
WillSync = 'will-sync',
InvalidSyncSession = 'invalid-sync-session',
LocalDatabaseReadError = 'local-database-read-error',
LocalDatabaseWriteError = 'local-database-write-error',
/** When a single roundtrip completes with sync, in a potentially multi-page sync request.
* If just a single roundtrip, this event will be triggered, along with CompletedFullSync */
CompletedIncrementalSync = 22,
/**
* When a single roundtrip completes with sync, in a potentially multi-page sync request.
* If just a single roundtrip, this event will be triggered, along with CompletedFullSync
*/
CompletedIncrementalSync = 'completed-incremental-sync',
/**
* The application has loaded all pending migrations (but not run any, except for the base one),
* and consumers may now call `hasPendingMigrations`
* and consumers may now call hasPendingMigrations
*/
MigrationsLoaded = 23,
MigrationsLoaded = 'migrations-loaded',
/** When StorageService is ready to start servicing read/write requests */
StorageReady = 24,
/** When StorageService is ready (but NOT yet decrypted) to start servicing read/write requests */
StorageReady = 'storage-ready',
PreferencesChanged = 'preferences-changed',
UnprotectedSessionBegan = 'unprotected-session-began',
UserRolesChanged = 'user-roles-changed',
FeaturesUpdated = 'features-updated',
UnprotectedSessionExpired = 'unprotected-session-expired',
PreferencesChanged = 25,
UnprotectedSessionBegan = 26,
UserRolesChanged = 27,
FeaturesUpdated = 28,
UnprotectedSessionExpired = 29,
/** Called when the app first launches and after first sync request made after sign in */
CompletedInitialSync = 30,
BiometricsSoftLockEngaged = 31,
BiometricsSoftLockDisengaged = 32,
DidPurchaseSubscription = 33,
CompletedInitialSync = 'completed-initial-sync',
BiometricsSoftLockEngaged = 'biometrics-soft-lock-engaged',
BiometricsSoftLockDisengaged = 'biometrics-soft-lock-disengaged',
DidPurchaseSubscription = 'did-purchase-subscription',
}
export type ApplicationStageChangedEventPayload = {
stage: ApplicationStage
}

View File

@@ -1,3 +1,10 @@
import {
AsymmetricMessageServerHash,
SharedVaultInviteServerHash,
SharedVaultServerHash,
UserEventServerHash,
} from '@standardnotes/responses'
/* istanbul ignore file */
export enum SyncEvent {
/**
@@ -7,8 +14,8 @@ export enum SyncEvent {
*/
SyncCompletedWithAllItemsUploaded = 'SyncCompletedWithAllItemsUploaded',
SyncCompletedWithAllItemsUploadedAndDownloaded = 'SyncCompletedWithAllItemsUploadedAndDownloaded',
SingleRoundTripSyncCompleted = 'SingleRoundTripSyncCompleted',
SyncWillBegin = 'sync:will-begin',
PaginatedSyncRequestCompleted = 'PaginatedSyncRequestCompleted',
SyncDidBeginProcessing = 'sync:did-begin-processing',
DownloadFirstSyncCompleted = 'sync:download-first-completed',
SyncTakingTooLong = 'sync:taking-too-long',
SyncError = 'sync:error',
@@ -22,4 +29,13 @@ export enum SyncEvent {
DatabaseWriteError = 'database-write-error',
DatabaseReadError = 'database-read-error',
SyncRequestsIntegrityCheck = 'sync:requests-integrity-check',
ReceivedRemoteSharedVaults = 'received-shared-vaults',
ReceivedSharedVaultInvites = 'received-shared-vault-invites',
ReceivedUserEvents = 'received-user-events',
ReceivedAsymmetricMessages = 'received-asymmetric-messages',
}
export type SyncEventReceivedRemoteSharedVaultsData = SharedVaultServerHash[]
export type SyncEventReceivedSharedVaultInvitesData = SharedVaultInviteServerHash[]
export type SyncEventReceivedAsymmetricMessagesData = AsymmetricMessageServerHash[]
export type SyncEventReceivedUserEventsData = UserEventServerHash[]

View File

@@ -3,16 +3,18 @@ import { FileItem } from '@standardnotes/models'
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
import { ChallengeServiceInterface } from '../Challenge'
import { InternalEventBusInterface } from '..'
import { InternalEventBusInterface, MutatorClientInterface } from '..'
import { AlertService } from '../Alert/AlertService'
import { ApiServiceInterface } from '../Api/ApiServiceInterface'
import { SyncServiceInterface } from '../Sync/SyncServiceInterface'
import { FileService } from './FileService'
import { BackupServiceInterface } from '@standardnotes/files'
import { HttpServiceInterface } from '@standardnotes/api'
describe('fileService', () => {
let apiService: ApiServiceInterface
let itemManager: ItemManagerInterface
let mutator: MutatorClientInterface
let syncService: SyncServiceInterface
let alertService: AlertService
let crypto: PureCryptoInterface
@@ -21,26 +23,28 @@ describe('fileService', () => {
let encryptor: EncryptionProviderInterface
let internalEventBus: InternalEventBusInterface
let backupService: BackupServiceInterface
let http: HttpServiceInterface
beforeEach(() => {
apiService = {} as jest.Mocked<ApiServiceInterface>
apiService.addEventObserver = jest.fn()
apiService.createFileValetToken = jest.fn()
apiService.createUserFileValetToken = jest.fn()
apiService.deleteFile = jest.fn().mockReturnValue({})
const numChunks = 1
apiService.downloadFile = jest
.fn()
.mockImplementation(
(
_file: string,
_chunkIndex: number,
_apiToken: string,
_rangeStart: number,
onBytesReceived: (bytes: Uint8Array) => void,
) => {
(params: {
_file: string
_chunkIndex: number
_apiToken: string
_ownershipType: string
_rangeStart: number
onBytesReceived: (bytes: Uint8Array) => void
}) => {
return new Promise<void>((resolve) => {
for (let i = 0; i < numChunks; i++) {
onBytesReceived(Uint8Array.from([0xaa]))
params.onBytesReceived(Uint8Array.from([0xaa]))
}
resolve()
@@ -49,11 +53,13 @@ describe('fileService', () => {
)
itemManager = {} as jest.Mocked<ItemManagerInterface>
itemManager.createItem = jest.fn()
itemManager.createTemplateItem = jest.fn().mockReturnValue({})
itemManager.setItemToBeDeleted = jest.fn()
itemManager.addObserver = jest.fn()
itemManager.changeItem = jest.fn()
mutator = {} as jest.Mocked<MutatorClientInterface>
mutator.createItem = jest.fn()
mutator.setItemToBeDeleted = jest.fn()
mutator.changeItem = jest.fn()
challengor = {} as jest.Mocked<ChallengeServiceInterface>
@@ -75,12 +81,15 @@ describe('fileService', () => {
backupService.readEncryptedFileFromBackup = jest.fn()
backupService.getFileBackupInfo = jest.fn()
http = {} as jest.Mocked<HttpServiceInterface>
fileService = new FileService(
apiService,
itemManager,
mutator,
syncService,
encryptor,
challengor,
http,
alertService,
crypto,
internalEventBus,
@@ -152,7 +161,7 @@ describe('fileService', () => {
} as jest.Mocked<FileItem>
const alertMock = (alertService.confirm = jest.fn().mockReturnValue(true))
const deleteItemMock = (itemManager.setItemToBeDeleted = jest.fn())
const deleteItemMock = (mutator.setItemToBeDeleted = jest.fn())
apiService.deleteFile = jest.fn().mockReturnValue({ data: { error: true } })

View File

@@ -1,4 +1,10 @@
import { ClientDisplayableError, isErrorResponse } from '@standardnotes/responses'
import { MutatorClientInterface } from './../Mutator/MutatorClientInterface'
import {
ClientDisplayableError,
ValetTokenOperation,
isClientDisplayableError,
isErrorResponse,
} from '@standardnotes/responses'
import { ContentType } from '@standardnotes/common'
import {
FileItem,
@@ -9,6 +15,8 @@ import {
FileContent,
EncryptedPayload,
isEncryptedPayload,
VaultListingInterface,
SharedVaultListingInterface,
} from '@standardnotes/models'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { spaceSeparatedStrings, UuidGenerator } from '@standardnotes/utils'
@@ -36,29 +44,37 @@ import {
import { AlertService, ButtonType } from '../Alert/AlertService'
import { ChallengeServiceInterface } from '../Challenge'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
import { AbstractService } from '../Service/AbstractService'
import { SyncServiceInterface } from '../Sync/SyncServiceInterface'
import { DecryptItemsKeyWithUserFallback } from '../Encryption/Functions'
import { log, LoggingDomain } from '../Logging'
import {
SharedVaultMoveType,
SharedVaultServer,
SharedVaultServerInterface,
HttpServiceInterface,
} from '@standardnotes/api'
const OneHundredMb = 100 * 1_000_000
export class FileService extends AbstractService implements FilesClientInterface {
private encryptedCache: FileMemoryCache = new FileMemoryCache(OneHundredMb)
private sharedVault: SharedVaultServerInterface
constructor(
private api: FilesApiInterface,
private itemManager: ItemManagerInterface,
private mutator: MutatorClientInterface,
private syncService: SyncServiceInterface,
private encryptor: EncryptionProviderInterface,
private challengor: ChallengeServiceInterface,
http: HttpServiceInterface,
private alertService: AlertService,
private crypto: PureCryptoInterface,
protected override internalEventBus: InternalEventBusInterface,
private backupsService?: BackupServiceInterface,
) {
super(internalEventBus)
this.sharedVault = new SharedVaultServer(http)
}
override deinit(): void {
@@ -67,7 +83,6 @@ export class FileService extends AbstractService implements FilesClientInterface
this.encryptedCache.clear()
;(this.encryptedCache as unknown) = undefined
;(this.api as unknown) = undefined
;(this.itemManager as unknown) = undefined
;(this.encryptor as unknown) = undefined
;(this.syncService as unknown) = undefined
;(this.alertService as unknown) = undefined
@@ -79,14 +94,109 @@ export class FileService extends AbstractService implements FilesClientInterface
return 5_000_000
}
private async createUserValetToken(
remoteIdentifier: string,
operation: ValetTokenOperation,
unencryptedFileSizeForUpload?: number | undefined,
): Promise<string | ClientDisplayableError> {
return this.api.createUserFileValetToken(remoteIdentifier, operation, unencryptedFileSizeForUpload)
}
private async createSharedVaultValetToken(params: {
sharedVaultUuid: string
remoteIdentifier: string
operation: ValetTokenOperation
fileUuidRequiredForExistingFiles?: string
unencryptedFileSizeForUpload?: number | undefined
moveOperationType?: SharedVaultMoveType
sharedVaultToSharedVaultMoveTargetUuid?: string
}): Promise<string | ClientDisplayableError> {
if (params.operation !== 'write' && !params.fileUuidRequiredForExistingFiles) {
throw new Error('File UUID is required for for non-write operations')
}
const valetTokenResponse = await this.sharedVault.createSharedVaultFileValetToken({
sharedVaultUuid: params.sharedVaultUuid,
fileUuid: params.fileUuidRequiredForExistingFiles,
remoteIdentifier: params.remoteIdentifier,
operation: params.operation,
unencryptedFileSize: params.unencryptedFileSizeForUpload,
moveOperationType: params.moveOperationType,
sharedVaultToSharedVaultMoveTargetUuid: params.sharedVaultToSharedVaultMoveTargetUuid,
})
if (isErrorResponse(valetTokenResponse)) {
return new ClientDisplayableError('Could not create valet token')
}
return valetTokenResponse.data.valetToken
}
public async moveFileToSharedVault(
file: FileItem,
sharedVault: SharedVaultListingInterface,
): Promise<void | ClientDisplayableError> {
const valetTokenResult = await this.createSharedVaultValetToken({
sharedVaultUuid: file.shared_vault_uuid ? file.shared_vault_uuid : sharedVault.sharing.sharedVaultUuid,
remoteIdentifier: file.remoteIdentifier,
operation: 'move',
fileUuidRequiredForExistingFiles: file.uuid,
moveOperationType: file.shared_vault_uuid ? 'shared-vault-to-shared-vault' : 'user-to-shared-vault',
sharedVaultToSharedVaultMoveTargetUuid: file.shared_vault_uuid ? sharedVault.sharing.sharedVaultUuid : undefined,
})
if (isClientDisplayableError(valetTokenResult)) {
return valetTokenResult
}
const moveResult = await this.api.moveFile(valetTokenResult)
if (!moveResult) {
return new ClientDisplayableError('Could not move file')
}
}
public async moveFileOutOfSharedVault(file: FileItem): Promise<void | ClientDisplayableError> {
if (!file.shared_vault_uuid) {
return new ClientDisplayableError('File is not in a shared vault')
}
const valetTokenResult = await this.createSharedVaultValetToken({
sharedVaultUuid: file.shared_vault_uuid,
remoteIdentifier: file.remoteIdentifier,
operation: 'move',
fileUuidRequiredForExistingFiles: file.uuid,
moveOperationType: 'shared-vault-to-user',
})
if (isClientDisplayableError(valetTokenResult)) {
return valetTokenResult
}
const moveResult = await this.api.moveFile(valetTokenResult)
if (!moveResult) {
return new ClientDisplayableError('Could not move file')
}
}
public async beginNewFileUpload(
sizeInBytes: number,
vault?: VaultListingInterface,
): Promise<EncryptAndUploadFileOperation | ClientDisplayableError> {
const remoteIdentifier = UuidGenerator.GenerateUuid()
const tokenResult = await this.api.createFileValetToken(remoteIdentifier, 'write', sizeInBytes)
const valetTokenResult =
vault && vault.isSharedVaultListing()
? await this.createSharedVaultValetToken({
sharedVaultUuid: vault.sharing.sharedVaultUuid,
remoteIdentifier,
operation: 'write',
unencryptedFileSizeForUpload: sizeInBytes,
})
: await this.createUserValetToken(remoteIdentifier, 'write', sizeInBytes)
if (tokenResult instanceof ClientDisplayableError) {
return tokenResult
if (valetTokenResult instanceof ClientDisplayableError) {
return valetTokenResult
}
const key = this.crypto.generateRandomKey(FileProtocolV1Constants.KeySize)
@@ -97,9 +207,18 @@ export class FileService extends AbstractService implements FilesClientInterface
decryptedSize: sizeInBytes,
}
const uploadOperation = new EncryptAndUploadFileOperation(fileParams, tokenResult, this.crypto, this.api)
const uploadOperation = new EncryptAndUploadFileOperation(
fileParams,
valetTokenResult,
this.crypto,
this.api,
vault,
)
const uploadSessionStarted = await this.api.startUploadSession(tokenResult)
const uploadSessionStarted = await this.api.startUploadSession(
valetTokenResult,
vault && vault.isSharedVaultListing() ? 'shared-vault' : 'user',
)
if (isErrorResponse(uploadSessionStarted) || !uploadSessionStarted.data.uploadId) {
return new ClientDisplayableError('Could not start upload session')
@@ -127,7 +246,10 @@ export class FileService extends AbstractService implements FilesClientInterface
operation: EncryptAndUploadFileOperation,
fileMetadata: FileMetadata,
): Promise<FileItem | ClientDisplayableError> {
const uploadSessionClosed = await this.api.closeUploadSession(operation.getApiToken())
const uploadSessionClosed = await this.api.closeUploadSession(
operation.getValetToken(),
operation.vault && operation.vault.isSharedVaultListing() ? 'shared-vault' : 'user',
)
if (!uploadSessionClosed) {
return new ClientDisplayableError('Could not close upload session')
@@ -145,10 +267,11 @@ export class FileService extends AbstractService implements FilesClientInterface
remoteIdentifier: result.remoteIdentifier,
}
const file = await this.itemManager.createItem<FileItem>(
const file = await this.mutator.createItem<FileItem>(
ContentType.File,
FillItemContentSpecialized(fileContent),
true,
operation.vault,
)
await this.syncService.sync()
@@ -215,7 +338,20 @@ export class FileService extends AbstractService implements FilesClientInterface
let cacheEntryAggregate = new Uint8Array()
const operation = new DownloadAndDecryptFileOperation(file, this.crypto, this.api)
const tokenResult = file.shared_vault_uuid
? await this.createSharedVaultValetToken({
sharedVaultUuid: file.shared_vault_uuid,
remoteIdentifier: file.remoteIdentifier,
operation: 'read',
fileUuidRequiredForExistingFiles: file.uuid,
})
: await this.createUserValetToken(file.remoteIdentifier, 'read')
if (tokenResult instanceof ClientDisplayableError) {
return tokenResult
}
const operation = new DownloadAndDecryptFileOperation(file, this.crypto, this.api, tokenResult)
const result = await operation.run(async ({ decrypted, encrypted, progress }): Promise<void> => {
if (addToCache) {
@@ -235,13 +371,20 @@ export class FileService extends AbstractService implements FilesClientInterface
public async deleteFile(file: FileItem): Promise<ClientDisplayableError | undefined> {
this.encryptedCache.remove(file.uuid)
const tokenResult = await this.api.createFileValetToken(file.remoteIdentifier, 'delete')
const tokenResult = file.shared_vault_uuid
? await this.createSharedVaultValetToken({
sharedVaultUuid: file.shared_vault_uuid,
remoteIdentifier: file.remoteIdentifier,
operation: 'delete',
fileUuidRequiredForExistingFiles: file.uuid,
})
: await this.createUserValetToken(file.remoteIdentifier, 'delete')
if (tokenResult instanceof ClientDisplayableError) {
return tokenResult
}
const result = await this.api.deleteFile(tokenResult)
const result = await this.api.deleteFile(tokenResult, file.shared_vault_uuid ? 'shared-vault' : 'user')
if (result.data?.error) {
const deleteAnyway = await this.alertService.confirm(
@@ -261,7 +404,7 @@ export class FileService extends AbstractService implements FilesClientInterface
}
}
await this.itemManager.setItemToBeDeleted(file)
await this.mutator.setItemToBeDeleted(file)
await this.syncService.sync()
return undefined

View File

@@ -0,0 +1,3 @@
export enum InternalFeature {
Vaults = 'vaults',
}

View File

@@ -0,0 +1,24 @@
import { InternalFeature } from './InternalFeature'
import { InternalFeatureServiceInterface } from './InternalFeatureServiceInterface'
let sharedInstance: InternalFeatureServiceInterface | undefined
export class InternalFeatureService implements InternalFeatureServiceInterface {
static get(): InternalFeatureServiceInterface {
if (!sharedInstance) {
sharedInstance = new InternalFeatureService()
}
return sharedInstance
}
private readonly enabledFeatures: Set<InternalFeature> = new Set()
isFeatureEnabled(feature: InternalFeature): boolean {
return this.enabledFeatures.has(feature)
}
enableFeature(feature: InternalFeature): void {
console.warn(`Enabling internal feature: ${feature}`)
this.enabledFeatures.add(feature)
}
}

View File

@@ -0,0 +1,6 @@
import { InternalFeature } from './InternalFeature'
export interface InternalFeatureServiceInterface {
isFeatureEnabled(feature: InternalFeature): boolean
enableFeature(feature: InternalFeature): void
}

View File

@@ -1,5 +0,0 @@
import { SNNote, SNTag, ItemCounts } from '@standardnotes/models'
export interface ItemCounterInterface {
countNotesAndTags(items: Array<SNNote | SNTag>): ItemCounts
}

View File

@@ -1,11 +1,7 @@
import { ContentType } from '@standardnotes/common'
import {
MutationType,
ItemsKeyInterface,
ItemsKeyMutatorInterface,
DecryptedItemInterface,
DecryptedItemMutator,
DecryptedPayloadInterface,
PayloadEmitSource,
EncryptedItemInterface,
DeletedItemInterface,
@@ -13,6 +9,20 @@ import {
PredicateInterface,
DecryptedPayload,
SNTag,
ItemInterface,
AnyItemInterface,
KeySystemIdentifier,
ItemCollection,
SNNote,
SmartView,
TagItemCountChangeObserver,
SNComponent,
SNTheme,
DecryptedPayloadInterface,
DecryptedTransferPayload,
FileItem,
VaultDisplayOptions,
NotesAndFilesDisplayControllerOptions,
} from '@standardnotes/models'
import { AbstractService } from '../Service/AbstractService'
@@ -41,26 +51,20 @@ export type ItemManagerChangeObserverCallback<I extends DecryptedItemInterface =
) => void
export interface ItemManagerInterface extends AbstractService {
getCollection(): ItemCollection
addObserver<I extends DecryptedItemInterface = DecryptedItemInterface>(
contentType: ContentType | ContentType[],
callback: ItemManagerChangeObserverCallback<I>,
): () => void
setItemToBeDeleted(itemToLookupUuidFor: DecryptedItemInterface, source?: PayloadEmitSource): Promise<void>
setItemsToBeDeleted(itemsToLookupUuidsFor: DecryptedItemInterface[]): Promise<void>
setItemsDirty(
itemsToLookupUuidsFor: DecryptedItemInterface[],
isUserModified?: boolean,
): Promise<DecryptedItemInterface[]>
get items(): DecryptedItemInterface[]
insertItem(item: DecryptedItemInterface): Promise<DecryptedItemInterface>
emitItemFromPayload(payload: DecryptedPayloadInterface, source: PayloadEmitSource): Promise<DecryptedItemInterface>
getItems<T extends DecryptedItemInterface>(contentType: ContentType | ContentType[]): T[]
get invalidItems(): EncryptedItemInterface[]
allTrackedItems(): ItemInterface[]
getDisplayableItemsKeys(): ItemsKeyInterface[]
createItem<T extends DecryptedItemInterface, C extends ItemContent = ItemContent>(
contentType: ContentType,
content: C,
needsSync?: boolean,
): Promise<T>
createTemplateItem<
C extends ItemContent = ItemContent,
I extends DecryptedItemInterface<C> = DecryptedItemInterface<C>,
@@ -69,23 +73,7 @@ export interface ItemManagerInterface extends AbstractService {
content?: C,
override?: Partial<DecryptedPayload<C>>,
): I
changeItem<
M extends DecryptedItemMutator = DecryptedItemMutator,
I extends DecryptedItemInterface = DecryptedItemInterface,
>(
itemToLookupUuidFor: I,
mutate?: (mutator: M) => void,
mutationType?: MutationType,
emitSource?: PayloadEmitSource,
payloadSourceKey?: string,
): Promise<I>
changeItemsKey(
itemToLookupUuidFor: ItemsKeyInterface,
mutate: (mutator: ItemsKeyMutatorInterface) => void,
mutationType?: MutationType,
emitSource?: PayloadEmitSource,
payloadSourceKey?: string,
): Promise<ItemsKeyInterface>
itemsMatchingPredicate<T extends DecryptedItemInterface>(
contentType: ContentType,
predicate: PredicateInterface<T>,
@@ -96,12 +84,47 @@ export interface ItemManagerInterface extends AbstractService {
): T[]
subItemsMatchingPredicates<T extends DecryptedItemInterface>(items: T[], predicates: PredicateInterface<T>[]): T[]
removeAllItemsFromMemory(): Promise<void>
removeItemsLocally(items: AnyItemInterface[]): void
getDirtyItems(): (DecryptedItemInterface | DeletedItemInterface)[]
getTagLongTitle(tag: SNTag): string
getSortedTagsForItem(item: DecryptedItemInterface<ItemContent>): SNTag[]
itemsReferencingItem<I extends DecryptedItemInterface = DecryptedItemInterface>(
itemToLookupUuidFor: { uuid: string },
contentType?: ContentType,
): I[]
referencesForItem<I extends DecryptedItemInterface = DecryptedItemInterface>(
itemToLookupUuidFor: DecryptedItemInterface,
contentType?: ContentType,
): I[]
findItem<T extends DecryptedItemInterface = DecryptedItemInterface>(uuid: string): T | undefined
findItems<T extends DecryptedItemInterface>(uuids: string[]): T[]
findSureItem<T extends DecryptedItemInterface = DecryptedItemInterface>(uuid: string): T
get trashedItems(): SNNote[]
itemsBelongingToKeySystem(systemIdentifier: KeySystemIdentifier): DecryptedItemInterface[]
hasTagsNeedingFoldersMigration(): boolean
get invalidNonVaultedItems(): EncryptedItemInterface[]
isTemplateItem(item: DecryptedItemInterface): boolean
getSmartViews(): SmartView[]
addNoteCountChangeObserver(observer: TagItemCountChangeObserver): () => void
allCountableNotesCount(): number
allCountableFilesCount(): number
countableNotesForTag(tag: SNTag | SmartView): number
getNoteCount(): number
getDisplayableTags(): SNTag[]
getTagChildren(itemToLookupUuidFor: SNTag): SNTag[]
getTagParent(itemToLookupUuidFor: SNTag): SNTag | undefined
isValidTagParent(parentTagToLookUpUuidFor: SNTag, childToLookUpUuidFor: SNTag): boolean
isSmartViewTitle(title: string): boolean
getDisplayableComponents(): (SNComponent | SNTheme)[]
createItemFromPayload(payload: DecryptedPayloadInterface): DecryptedItemInterface
createPayloadFromObject(object: DecryptedTransferPayload): DecryptedPayloadInterface
getDisplayableFiles(): FileItem[]
setVaultDisplayOptions(options: VaultDisplayOptions): void
numberOfNotesWithConflicts(): number
getDisplayableNotes(): SNNote[]
getDisplayableNotesAndFiles(): (SNNote | FileItem)[]
setPrimaryItemDisplayOptions(options: NotesAndFilesDisplayControllerOptions): void
getTagPrefixTitle(tag: SNTag): string | undefined
getNoteLinkedFiles(note: SNNote): FileItem[]
conflictsOf(uuid: string): AnyItemInterface[]
}

View File

@@ -1,174 +0,0 @@
/* istanbul ignore file */
import { ContentType } from '@standardnotes/common'
import {
SNNote,
FileItem,
SNTag,
SmartView,
TagItemCountChangeObserver,
DecryptedPayloadInterface,
EncryptedItemInterface,
DecryptedTransferPayload,
PredicateInterface,
DecryptedItemInterface,
SNComponent,
SNTheme,
DisplayOptions,
ItemsKeyInterface,
ItemContent,
DecryptedPayload,
AnyItemInterface,
} from '@standardnotes/models'
export interface ItemsClientInterface {
get invalidItems(): EncryptedItemInterface[]
associateFileWithNote(file: FileItem, note: SNNote): Promise<FileItem>
disassociateFileWithNote(file: FileItem, note: SNNote): Promise<FileItem>
renameFile(file: FileItem, name: string): Promise<FileItem>
addTagToNote(note: SNNote, tag: SNTag, addHierarchy: boolean): Promise<SNTag[]>
addTagToFile(file: FileItem, tag: SNTag, addHierarchy: boolean): Promise<SNTag[]>
/** Creates an unmanaged, un-inserted item from a payload. */
createItemFromPayload(payload: DecryptedPayloadInterface): DecryptedItemInterface
createPayloadFromObject(object: DecryptedTransferPayload): DecryptedPayloadInterface
createTemplateItem<
C extends ItemContent = ItemContent,
I extends DecryptedItemInterface<C> = DecryptedItemInterface<C>,
>(
contentType: ContentType,
content?: C,
override?: Partial<DecryptedPayload<C>>,
): I
get trashedItems(): SNNote[]
setPrimaryItemDisplayOptions(options: DisplayOptions): void
getDisplayableNotes(): SNNote[]
getDisplayableTags(): SNTag[]
getDisplayableItemsKeys(): ItemsKeyInterface[]
getDisplayableFiles(): FileItem[]
getDisplayableNotesAndFiles(): (SNNote | FileItem)[]
getDisplayableComponents(): (SNComponent | SNTheme)[]
getItems<T extends DecryptedItemInterface>(contentType: ContentType | ContentType[]): T[]
insertItem(item: DecryptedItemInterface): Promise<DecryptedItemInterface>
notesMatchingSmartView(view: SmartView): SNNote[]
addNoteCountChangeObserver(observer: TagItemCountChangeObserver): () => void
allCountableNotesCount(): number
allCountableFilesCount(): number
countableNotesForTag(tag: SNTag | SmartView): number
findTagByTitle(title: string): SNTag | undefined
getTagPrefixTitle(tag: SNTag): string | undefined
getTagLongTitle(tag: SNTag): string
hasTagsNeedingFoldersMigration(): boolean
referencesForItem(itemToLookupUuidFor: DecryptedItemInterface, contentType?: ContentType): DecryptedItemInterface[]
itemsReferencingItem(itemToLookupUuidFor: DecryptedItemInterface, contentType?: ContentType): DecryptedItemInterface[]
linkNoteToNote(note: SNNote, otherNote: SNNote): Promise<SNNote>
linkFileToFile(file: FileItem, otherFile: FileItem): Promise<FileItem>
unlinkItems(
itemOne: DecryptedItemInterface<ItemContent>,
itemTwo: DecryptedItemInterface<ItemContent>,
): Promise<DecryptedItemInterface<ItemContent>>
/**
* Finds tags with title or component starting with a search query and (optionally) not associated with a note
* @param searchQuery - The query string to match
* @param note - The note whose tags should be omitted from results
* @returns Array containing tags matching search query and not associated with note
*/
searchTags(searchQuery: string, note?: SNNote): SNTag[]
isValidTagParent(parentTagToLookUpUuidFor: SNTag, childToLookUpUuidFor: SNTag): boolean
/**
* Returns the parent for a tag
*/
getTagParent(itemToLookupUuidFor: SNTag): SNTag | undefined
/**
* Returns the hierarchy of parents for a tag
* @returns Array containing all parent tags
*/
getTagParentChain(itemToLookupUuidFor: SNTag): SNTag[]
/**
* Returns all descendants for a tag
* @returns Array containing all descendant tags
*/
getTagChildren(itemToLookupUuidFor: SNTag): SNTag[]
/**
* Get tags for a note sorted in natural order
* @param item - The item whose tags will be returned
* @returns Array containing tags associated with an item
*/
getSortedTagsForItem(item: DecryptedItemInterface<ItemContent>): SNTag[]
isSmartViewTitle(title: string): boolean
getSmartViews(): SmartView[]
getNoteCount(): number
/**
* Finds an item by UUID.
*/
findItem<T extends DecryptedItemInterface = DecryptedItemInterface>(uuid: string): T | undefined
/**
* Finds an item by predicate.
*/
findItems<T extends DecryptedItemInterface>(uuids: string[]): T[]
findSureItem<T extends DecryptedItemInterface = DecryptedItemInterface>(uuid: string): T
/**
* Finds an item by predicate.
*/
itemsMatchingPredicate<T extends DecryptedItemInterface>(
contentType: ContentType,
predicate: PredicateInterface<T>,
): T[]
/**
* @param item item to be checked
* @returns Whether the item is a template (unmanaged)
*/
isTemplateItem(item: DecryptedItemInterface): boolean
createSmartView<T extends DecryptedItemInterface<ItemContent>>(
title: string,
predicate: PredicateInterface<T>,
iconString?: string,
): Promise<SmartView>
conflictsOf(uuid: string): AnyItemInterface[]
numberOfNotesWithConflicts(): number
}

View File

@@ -1,9 +1,9 @@
import { ContentType } from '@standardnotes/common'
import { SNNote, SNTag } from '@standardnotes/models'
import { ItemCounter } from './ItemCounter'
import { StaticItemCounter } from './StaticItemCounter'
describe('ItemCounter', () => {
const createCounter = () => new ItemCounter()
const createCounter = () => new StaticItemCounter()
it('should count distinct item counts', () => {
const items = [

View File

@@ -1,9 +1,7 @@
import { ContentType } from '@standardnotes/common'
import { SNNote, SNTag, ItemCounts } from '@standardnotes/models'
import { ItemCounterInterface } from './ItemCounterInterface'
export class ItemCounter implements ItemCounterInterface {
export class StaticItemCounter {
countNotesAndTags(items: Array<SNNote | SNTag>): ItemCounts {
const counts: ItemCounts = {
notes: 0,

View File

@@ -0,0 +1,158 @@
import { MutatorClientInterface } from './../Mutator/MutatorClientInterface'
import { ApplicationStage } from './../Application/ApplicationStage'
import { InternalEventBusInterface } from './../Internal/InternalEventBusInterface'
import { StorageServiceInterface } from './../Storage/StorageServiceInterface'
import {
DecryptedPayload,
DecryptedTransferPayload,
EncryptedItemInterface,
KeySystemIdentifier,
KeySystemItemsKeyInterface,
KeySystemRootKey,
KeySystemRootKeyContent,
KeySystemRootKeyInterface,
KeySystemRootKeyStorageMode,
Predicate,
VaultListingInterface,
} from '@standardnotes/models'
import { ItemManagerInterface } from './../Item/ItemManagerInterface'
import { ContentType } from '@standardnotes/common'
import { KeySystemKeyManagerInterface } from '@standardnotes/encryption'
import { AbstractService } from '../Service/AbstractService'
const RootKeyStorageKeyPrefix = 'key-system-root-key-'
export class KeySystemKeyManager extends AbstractService implements KeySystemKeyManagerInterface {
private rootKeyMemoryCache: Record<KeySystemIdentifier, KeySystemRootKeyInterface> = {}
constructor(
private readonly items: ItemManagerInterface,
private readonly mutator: MutatorClientInterface,
private readonly storage: StorageServiceInterface,
eventBus: InternalEventBusInterface,
) {
super(eventBus)
}
public override async handleApplicationStage(stage: ApplicationStage): Promise<void> {
if (stage === ApplicationStage.StorageDecrypted_09) {
this.loadRootKeysFromStorage()
}
}
private loadRootKeysFromStorage(): void {
const storageKeys = this.storage.getAllKeys().filter((key) => key.startsWith(RootKeyStorageKeyPrefix))
const keyRawPayloads = storageKeys.map((key) =>
this.storage.getValue<DecryptedTransferPayload<KeySystemRootKeyContent>>(key),
)
const keyPayloads = keyRawPayloads.map((rawPayload) => new DecryptedPayload<KeySystemRootKeyContent>(rawPayload))
const keys = keyPayloads.map((payload) => new KeySystemRootKey(payload))
keys.forEach((key) => {
this.rootKeyMemoryCache[key.systemIdentifier] = key
})
}
private storageKeyForRootKey(systemIdentifier: KeySystemIdentifier): string {
return `${RootKeyStorageKeyPrefix}${systemIdentifier}`
}
public intakeNonPersistentKeySystemRootKey(
key: KeySystemRootKeyInterface,
storage: KeySystemRootKeyStorageMode,
): void {
this.rootKeyMemoryCache[key.systemIdentifier] = key
if (storage === KeySystemRootKeyStorageMode.Local) {
this.storage.setValue(this.storageKeyForRootKey(key.systemIdentifier), key.payload.ejected())
}
}
public undoIntakeNonPersistentKeySystemRootKey(systemIdentifier: KeySystemIdentifier): void {
delete this.rootKeyMemoryCache[systemIdentifier]
void this.storage.removeValue(this.storageKeyForRootKey(systemIdentifier))
}
public getAllSyncedKeySystemRootKeys(): KeySystemRootKeyInterface[] {
return this.items.getItems(ContentType.KeySystemRootKey)
}
public clearMemoryOfKeysRelatedToVault(vault: VaultListingInterface): void {
delete this.rootKeyMemoryCache[vault.systemIdentifier]
const itemsKeys = this.getKeySystemItemsKeys(vault.systemIdentifier)
this.items.removeItemsLocally(itemsKeys)
}
public getSyncedKeySystemRootKeysForVault(systemIdentifier: KeySystemIdentifier): KeySystemRootKeyInterface[] {
return this.items.itemsMatchingPredicate<KeySystemRootKeyInterface>(
ContentType.KeySystemRootKey,
new Predicate<KeySystemRootKeyInterface>('systemIdentifier', '=', systemIdentifier),
)
}
public getAllKeySystemRootKeysForVault(systemIdentifier: KeySystemIdentifier): KeySystemRootKeyInterface[] {
const synced = this.getSyncedKeySystemRootKeysForVault(systemIdentifier)
const memory = this.rootKeyMemoryCache[systemIdentifier] ? [this.rootKeyMemoryCache[systemIdentifier]] : []
return [...synced, ...memory]
}
public async deleteNonPersistentSystemRootKeysForVault(systemIdentifier: KeySystemIdentifier): Promise<void> {
delete this.rootKeyMemoryCache[systemIdentifier]
await this.storage.removeValue(this.storageKeyForRootKey(systemIdentifier))
}
public async deleteAllSyncedKeySystemRootKeys(systemIdentifier: KeySystemIdentifier): Promise<void> {
const keys = this.getSyncedKeySystemRootKeysForVault(systemIdentifier)
await this.mutator.setItemsToBeDeleted(keys)
}
public getKeySystemRootKeyWithToken(
systemIdentifier: KeySystemIdentifier,
rootKeyToken: string,
): KeySystemRootKeyInterface | undefined {
const keys = this.getAllKeySystemRootKeysForVault(systemIdentifier).filter((key) => key.token === rootKeyToken)
if (keys.length > 1) {
throw new Error('Multiple synced key system root keys found for token')
}
return keys[0]
}
public getPrimaryKeySystemRootKey(systemIdentifier: KeySystemIdentifier): KeySystemRootKeyInterface | undefined {
const keys = this.getAllKeySystemRootKeysForVault(systemIdentifier)
const sortedByNewestFirst = keys.sort((a, b) => b.keyParams.creationTimestamp - a.keyParams.creationTimestamp)
return sortedByNewestFirst[0]
}
public getAllKeySystemItemsKeys(): (KeySystemItemsKeyInterface | EncryptedItemInterface)[] {
const decryptedItems = this.items.getItems<KeySystemItemsKeyInterface>(ContentType.KeySystemItemsKey)
const encryptedItems = this.items.invalidItems.filter((item) => item.content_type === ContentType.KeySystemItemsKey)
return [...decryptedItems, ...encryptedItems]
}
public getKeySystemItemsKeys(systemIdentifier: KeySystemIdentifier): KeySystemItemsKeyInterface[] {
return this.items
.getItems<KeySystemItemsKeyInterface>(ContentType.KeySystemItemsKey)
.filter((key) => key.key_system_identifier === systemIdentifier)
}
public getPrimaryKeySystemItemsKey(systemIdentifier: KeySystemIdentifier): KeySystemItemsKeyInterface {
const rootKey = this.getPrimaryKeySystemRootKey(systemIdentifier)
if (!rootKey) {
throw new Error('No primary key system root key found')
}
const matchingItemsKeys = this.getKeySystemItemsKeys(systemIdentifier).filter(
(key) => key.rootKeyToken === rootKey.token,
)
const sortedByNewestFirst = matchingItemsKeys.sort((a, b) => b.creationTimestamp - a.creationTimestamp)
return sortedByNewestFirst[0]
}
}

View File

@@ -0,0 +1,146 @@
import { HistoryServiceInterface } from '../History/HistoryServiceInterface'
import { ChallengeServiceInterface } from '../Challenge/ChallengeServiceInterface'
import { PayloadManagerInterface } from '../Payloads/PayloadManagerInterface'
import { ProtectionsClientInterface } from '../Protection/ProtectionClientInterface'
import { SyncServiceInterface } from '../Sync/SyncServiceInterface'
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
import { ContentType, ProtocolVersion, compareVersions } from '@standardnotes/common'
import {
BackupFile,
BackupFileDecryptedContextualPayload,
ComponentContent,
CopyPayloadWithContentOverride,
CreateDecryptedBackupFileContextPayload,
CreateEncryptedBackupFileContextPayload,
DecryptedItemInterface,
DecryptedPayloadInterface,
isDecryptedPayload,
isEncryptedTransferPayload,
} from '@standardnotes/models'
import { ClientDisplayableError } from '@standardnotes/responses'
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import { Challenge, ChallengePrompt, ChallengeReason, ChallengeValidation } from '../Challenge'
const Strings = {
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.",
FileAccountPassword: 'File account password',
}
export type ImportDataReturnType =
| {
affectedItems: DecryptedItemInterface[]
errorCount: number
}
| {
error: ClientDisplayableError
}
export class ImportDataUseCase {
constructor(
private itemManager: ItemManagerInterface,
private syncService: SyncServiceInterface,
private protectionService: ProtectionsClientInterface,
private encryption: EncryptionProviderInterface,
private payloadManager: PayloadManagerInterface,
private challengeService: ChallengeServiceInterface,
private historyService: HistoryServiceInterface,
) {}
/**
* @returns
* .affectedItems: Items that were either created or dirtied by this import
* .errorCount: The number of items that were not imported due to failure to decrypt.
*/
async execute(data: BackupFile, awaitSync = false): Promise<ImportDataReturnType> {
if (data.version) {
/**
* Prior to 003 backup files did not have a version field so we cannot
* stop importing if there is no backup file version, only if there is
* an unsupported version.
*/
const version = data.version as ProtocolVersion
const supportedVersions = this.encryption.supportedVersions()
if (!supportedVersions.includes(version)) {
return { error: new ClientDisplayableError(Strings.UnsupportedBackupFileVersion) }
}
const userVersion = this.encryption.getUserVersion()
if (userVersion && compareVersions(version, userVersion) === 1) {
/** File was made with a greater version than the user's account */
return { error: new ClientDisplayableError(Strings.BackupFileMoreRecentThanAccount) }
}
}
let password: string | undefined
if (data.auth_params || data.keyParams) {
/** Get import file password. */
const challenge = new Challenge(
[new ChallengePrompt(ChallengeValidation.None, Strings.FileAccountPassword, undefined, true)],
ChallengeReason.DecryptEncryptedFile,
true,
)
const passwordResponse = await this.challengeService.promptForChallengeResponse(challenge)
if (passwordResponse == undefined) {
/** Challenge was canceled */
return { error: new ClientDisplayableError('Import aborted') }
}
this.challengeService.completeChallenge(challenge)
password = passwordResponse?.values[0].value as string
}
if (!(await this.protectionService.authorizeFileImport())) {
return { error: new ClientDisplayableError('Import aborted') }
}
data.items = data.items.map((item) => {
if (isEncryptedTransferPayload(item)) {
return CreateEncryptedBackupFileContextPayload(item)
} else {
return CreateDecryptedBackupFileContextPayload(item as BackupFileDecryptedContextualPayload)
}
})
const decryptedPayloadsOrError = await this.encryption.decryptBackupFile(data, password)
if (decryptedPayloadsOrError instanceof ClientDisplayableError) {
return { error: decryptedPayloadsOrError }
}
const validPayloads = decryptedPayloadsOrError.filter(isDecryptedPayload).map((payload) => {
/* Don't want to activate any components during import process in
* case of exceptions breaking up the import proccess */
if (payload.content_type === ContentType.Component && (payload.content as ComponentContent).active) {
const typedContent = payload as DecryptedPayloadInterface<ComponentContent>
return CopyPayloadWithContentOverride(typedContent, {
active: false,
})
} else {
return payload
}
})
const affectedUuids = await this.payloadManager.importPayloads(
validPayloads,
this.historyService.getHistoryMapCopy(),
)
const promise = this.syncService.sync()
if (awaitSync) {
await promise
}
const affectedItems = this.itemManager.findItems(affectedUuids) as DecryptedItemInterface[]
return {
affectedItems: affectedItems,
errorCount: decryptedPayloadsOrError.length - validPayloads.length,
}
}
}

View File

@@ -1,70 +1,92 @@
import { ContentType } from '@standardnotes/common'
import {
BackupFile,
ComponentMutator,
DecryptedItemInterface,
DecryptedItemMutator,
DecryptedPayload,
DecryptedPayloadInterface,
EncryptedItemInterface,
FeatureRepoMutator,
FileItem,
ItemContent,
ItemsKeyInterface,
ItemsKeyMutatorInterface,
MutationType,
PayloadEmitSource,
PredicateInterface,
SmartView,
SNComponent,
SNFeatureRepo,
SNNote,
SNTag,
TransactionalMutation,
VaultListingInterface,
} from '@standardnotes/models'
import { ClientDisplayableError } from '@standardnotes/responses'
import { ChallengeReason } from '../Challenge/Types/ChallengeReason'
import { SyncOptions } from '../Sync/SyncOptions'
export interface MutatorClientInterface {
/**
* Inserts the input item by its payload properties, and marks the item as dirty.
* A sync is not performed after an item is inserted. This must be handled by the caller.
*/
insertItem(item: DecryptedItemInterface): Promise<DecryptedItemInterface>
insertItem<T extends DecryptedItemInterface>(item: DecryptedItemInterface, setDirty?: boolean): Promise<T>
emitItemFromPayload(payload: DecryptedPayloadInterface, source: PayloadEmitSource): Promise<DecryptedItemInterface>
/**
* Mutates a pre-existing item, marks it as dirty, and syncs it
*/
changeAndSaveItem<M extends DecryptedItemMutator = DecryptedItemMutator>(
itemToLookupUuidFor: DecryptedItemInterface,
mutate: (mutator: M) => void,
updateTimestamps?: boolean,
emitSource?: PayloadEmitSource,
syncOptions?: SyncOptions,
): Promise<DecryptedItemInterface | undefined>
/**
* Mutates pre-existing items, marks them as dirty, and syncs
*/
changeAndSaveItems<M extends DecryptedItemMutator = DecryptedItemMutator>(
setItemToBeDeleted(itemToLookupUuidFor: DecryptedItemInterface, source?: PayloadEmitSource): Promise<void>
setItemsToBeDeleted(itemsToLookupUuidsFor: DecryptedItemInterface[]): Promise<void>
setItemsDirty(
itemsToLookupUuidsFor: DecryptedItemInterface[],
mutate: (mutator: M) => void,
updateTimestamps?: boolean,
isUserModified?: boolean,
): Promise<DecryptedItemInterface[]>
createItem<T extends DecryptedItemInterface, C extends ItemContent = ItemContent>(
contentType: ContentType,
content: C,
needsSync?: boolean,
vault?: VaultListingInterface,
): Promise<T>
changeItem<
M extends DecryptedItemMutator = DecryptedItemMutator,
I extends DecryptedItemInterface = DecryptedItemInterface,
>(
itemToLookupUuidFor: I,
mutate?: (mutator: M) => void,
mutationType?: MutationType,
emitSource?: PayloadEmitSource,
syncOptions?: SyncOptions,
): Promise<void>
payloadSourceKey?: string,
): Promise<I>
changeItems<
M extends DecryptedItemMutator = DecryptedItemMutator,
I extends DecryptedItemInterface = DecryptedItemInterface,
>(
itemsToLookupUuidsFor: I[],
mutate?: (mutator: M) => void,
mutationType?: MutationType,
emitSource?: PayloadEmitSource,
payloadSourceKey?: string,
): Promise<I[]>
/**
* Mutates a pre-existing item and marks it as dirty. Does not sync changes.
*/
changeItem<M extends DecryptedItemMutator>(
itemToLookupUuidFor: DecryptedItemInterface,
mutate: (mutator: M) => void,
updateTimestamps?: boolean,
): Promise<DecryptedItemInterface | undefined>
changeItemsKey(
itemToLookupUuidFor: ItemsKeyInterface,
mutate: (mutator: ItemsKeyMutatorInterface) => void,
mutationType?: MutationType,
emitSource?: PayloadEmitSource,
payloadSourceKey?: string,
): Promise<ItemsKeyInterface>
/**
* Mutates a pre-existing items and marks them as dirty. Does not sync changes.
*/
changeItems<M extends DecryptedItemMutator = DecryptedItemMutator>(
itemsToLookupUuidsFor: DecryptedItemInterface[],
mutate: (mutator: M) => void,
updateTimestamps?: boolean,
): Promise<(DecryptedItemInterface | undefined)[]>
changeComponent(
itemToLookupUuidFor: SNComponent,
mutate: (mutator: ComponentMutator) => void,
mutationType?: MutationType,
emitSource?: PayloadEmitSource,
payloadSourceKey?: string,
): Promise<SNComponent>
changeFeatureRepo(
itemToLookupUuidFor: SNFeatureRepo,
mutate: (mutator: FeatureRepoMutator) => void,
mutationType?: MutationType,
emitSource?: PayloadEmitSource,
payloadSourceKey?: string,
): Promise<SNFeatureRepo>
/**
* Run unique mutations per each item in the array, then only propagate all changes
@@ -83,44 +105,11 @@ export interface MutatorClientInterface {
payloadSourceKey?: string,
): Promise<DecryptedItemInterface | undefined>
protectItems<_M extends DecryptedItemMutator<ItemContent>, I extends DecryptedItemInterface<ItemContent>>(
items: I[],
): Promise<I[]>
unprotectItems<_M extends DecryptedItemMutator<ItemContent>, I extends DecryptedItemInterface<ItemContent>>(
items: I[],
reason: ChallengeReason,
): Promise<I[] | undefined>
protectNote(note: SNNote): Promise<SNNote>
unprotectNote(note: SNNote): Promise<SNNote | undefined>
protectNotes(notes: SNNote[]): Promise<SNNote[]>
unprotectNotes(notes: SNNote[]): Promise<SNNote[]>
protectFile(file: FileItem): Promise<FileItem>
unprotectFile(file: FileItem): Promise<FileItem | undefined>
/**
* Takes the values of the input item and emits it onto global state.
*/
mergeItem(item: DecryptedItemInterface, source: PayloadEmitSource): Promise<DecryptedItemInterface>
/**
* Creates an unmanaged item that can be added later.
*/
createTemplateItem<
C extends ItemContent = ItemContent,
I extends DecryptedItemInterface<C> = DecryptedItemInterface<C>,
>(
contentType: ContentType,
content?: C,
override?: Partial<DecryptedPayload<C>>,
): I
/**
* @param isUserModified Whether to change the modified date the user
* sees of the item.
@@ -135,7 +124,13 @@ export interface MutatorClientInterface {
emptyTrash(): Promise<void>
duplicateItem<T extends DecryptedItemInterface>(item: T, additionalContent?: Partial<T['content']>): Promise<T>
duplicateItem<T extends DecryptedItemInterface>(
itemToLookupUuidFor: T,
isConflict?: boolean,
additionalContent?: Partial<T['content']>,
): Promise<T>
addTagToNote(note: SNNote, tag: SNTag, addHierarchy: boolean): Promise<SNTag[] | undefined>
/**
* Migrates any tags containing a '.' character to sa chema-based heirarchy, removing
@@ -146,41 +141,35 @@ export interface MutatorClientInterface {
/**
* Establishes a hierarchical relationship between two tags.
*/
setTagParent(parentTag: SNTag, childTag: SNTag): Promise<void>
setTagParent(parentTag: SNTag, childTag: SNTag): Promise<SNTag>
/**
* Remove the tag parent.
*/
unsetTagParent(childTag: SNTag): Promise<void>
unsetTagParent(childTag: SNTag): Promise<SNTag>
findOrCreateTag(title: string): Promise<SNTag>
findOrCreateTag(title: string, createInVault?: VaultListingInterface): Promise<SNTag>
/** Creates and returns the tag but does not run sync. Callers must perform sync. */
createTagOrSmartView(title: string): Promise<SNTag | SmartView>
createTagOrSmartView<T extends SNTag | SmartView>(title: string, vault?: VaultListingInterface): Promise<T>
findOrCreateTagParentChain(titlesHierarchy: string[]): Promise<SNTag>
/**
* Activates or deactivates a component, depending on its
* current state, and syncs.
*/
toggleComponent(component: SNComponent): Promise<void>
associateFileWithNote(file: FileItem, note: SNNote): Promise<FileItem | undefined>
toggleTheme(theme: SNComponent): Promise<void>
disassociateFileWithNote(file: FileItem, note: SNNote): Promise<FileItem>
renameFile(file: FileItem, name: string): Promise<FileItem>
/**
* @returns
* .affectedItems: Items that were either created or dirtied by this import
* .errorCount: The number of items that were not imported due to failure to decrypt.
*/
importData(
data: BackupFile,
awaitSync?: boolean,
): Promise<
| {
affectedItems: DecryptedItemInterface[]
errorCount: number
}
| {
error: ClientDisplayableError
}
>
unlinkItems(
itemA: DecryptedItemInterface<ItemContent>,
itemB: DecryptedItemInterface<ItemContent>,
): Promise<DecryptedItemInterface<ItemContent>>
createSmartView<T extends DecryptedItemInterface>(dto: {
title: string
predicate: PredicateInterface<T>
iconString?: string
vault?: VaultListingInterface
}): Promise<SmartView>
linkNoteToNote(note: SNNote, otherNote: SNNote): Promise<SNNote>
linkFileToFile(file: FileItem, otherFile: FileItem): Promise<FileItem>
addTagToFile(file: FileItem, tag: SNTag, addHierarchy: boolean): Promise<SNTag[] | undefined>
}

View File

@@ -25,4 +25,6 @@ export interface PayloadManagerInterface {
get nonDeletedItems(): FullyFormedPayloadInterface[]
importPayloads(payloads: DecryptedPayloadInterface[], historyMap: HistoryMap): Promise<string[]>
removePayloadLocally(payload: FullyFormedPayloadInterface | FullyFormedPayloadInterface[]): void
}

View File

@@ -1,4 +1,4 @@
import { DecryptedItem } from '@standardnotes/models'
import { DecryptedItem, DecryptedItemInterface, FileItem, SNNote } from '@standardnotes/models'
import { ChallengeReason } from '../Challenge'
import { MobileUnlockTiming } from './MobileUnlockTiming'
import { TimingDisplayOption } from './TimingDisplayOption'
@@ -24,4 +24,13 @@ export interface ProtectionsClientInterface {
authorizeAddingPasscode(): Promise<boolean>
authorizeRemovingPasscode(): Promise<boolean>
authorizeChangingPasscode(): Promise<boolean>
authorizeFileImport(): Promise<boolean>
protectItems<I extends DecryptedItemInterface>(items: I[]): Promise<I[]>
unprotectItems<I extends DecryptedItemInterface>(items: I[], reason: ChallengeReason): Promise<I[] | undefined>
protectNote(note: SNNote): Promise<SNNote>
unprotectNote(note: SNNote): Promise<SNNote | undefined>
protectNotes(notes: SNNote[]): Promise<SNNote[]>
unprotectNotes(notes: SNNote[]): Promise<SNNote[]>
protectFile(file: FileItem): Promise<FileItem>
unprotectFile(file: FileItem): Promise<FileItem | undefined>
}

View File

@@ -1,4 +1,5 @@
import { Uuid } from '@standardnotes/domain-core'
import { RevisionPayload } from './RevisionPayload'
export interface RevisionClientInterface {
listRevisions(itemUuid: Uuid): Promise<
@@ -11,18 +12,5 @@ export interface RevisionClientInterface {
}>
>
deleteRevision(itemUuid: Uuid, revisionUuid: Uuid): Promise<string>
getRevision(
itemUuid: Uuid,
revisionUuid: Uuid,
): Promise<{
uuid: string
item_uuid: string
content: string | null
content_type: string
items_key_id: string | null
enc_item_key: string | null
auth_hash: string | null
created_at: string
updated_at: string
} | null>
getRevision(itemUuid: Uuid, revisionUuid: Uuid): Promise<RevisionPayload | null>
}

View File

@@ -5,6 +5,7 @@ import { isErrorResponse } from '@standardnotes/responses'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { AbstractService } from '../Service/AbstractService'
import { RevisionClientInterface } from './RevisionClientInterface'
import { RevisionPayload } from './RevisionPayload'
export class RevisionManager extends AbstractService implements RevisionClientInterface {
constructor(
@@ -36,20 +37,7 @@ export class RevisionManager extends AbstractService implements RevisionClientIn
return result.data.message
}
async getRevision(
itemUuid: Uuid,
revisionUuid: Uuid,
): Promise<{
uuid: string
item_uuid: string
content: string | null
content_type: string
items_key_id: string | null
enc_item_key: string | null
auth_hash: string | null
created_at: string
updated_at: string
} | null> {
async getRevision(itemUuid: Uuid, revisionUuid: Uuid): Promise<RevisionPayload | null> {
const result = await this.revisionApiService.getRevision(itemUuid.value, revisionUuid.value)
if (isErrorResponse(result)) {

View File

@@ -0,0 +1,14 @@
export type RevisionPayload = {
uuid: string
item_uuid: string
content: string | null
content_type: string
items_key_id: string | null
enc_item_key: string | null
auth_hash: string | null
created_at: string
updated_at: string
user_uuid: string
key_system_identifier: string | null
shared_vault_uuid: string | null
}

View File

@@ -8,13 +8,15 @@ import { ApplicationStage } from '../Application/ApplicationStage'
import { InternalEventPublishStrategy } from '../Internal/InternalEventPublishStrategy'
import { DiagnosticInfo } from '../Diagnostics/ServiceDiagnostics'
export abstract class AbstractService<EventName = string, EventData = undefined>
export abstract class AbstractService<EventName = string, EventData = unknown>
implements ServiceInterface<EventName, EventData>
{
private eventObservers: EventObserver<EventName, EventData>[] = []
public loggingEnabled = false
private criticalPromises: Promise<unknown>[] = []
protected eventDisposers: (() => void)[] = []
constructor(protected internalEventBus: InternalEventBusInterface) {}
public addEventObserver(observer: EventObserver<EventName, EventData>): () => void {
@@ -71,6 +73,11 @@ export abstract class AbstractService<EventName = string, EventData = undefined>
this.eventObservers.length = 0
;(this.internalEventBus as unknown) = undefined
;(this.criticalPromises as unknown) = undefined
for (const disposer of this.eventDisposers) {
disposer()
}
this.eventDisposers = []
}
/**

View File

@@ -0,0 +1,5 @@
export enum SessionEvent {
Restored = 'SessionRestored',
Revoked = 'SessionRevoked',
UserKeyPairChanged = 'UserKeyPairChanged',
}

View File

@@ -10,7 +10,11 @@ import { SessionManagerResponse } from './SessionManagerResponse'
export interface SessionsClientInterface {
getWorkspaceDisplayIdentifier(): string
populateSessionFromDemoShareToken(token: Base64String): Promise<void>
getUser(): User | undefined
get userUuid(): string
getSureUser(): User
isCurrentSessionReadOnly(): boolean | undefined
register(email: string, password: string, ephemeral: boolean): Promise<UserRegistrationResponseBody>
signIn(
@@ -20,7 +24,7 @@ export interface SessionsClientInterface {
ephemeral: boolean,
minAllowedVersion?: ProtocolVersion,
): Promise<SessionManagerResponse>
getSureUser(): User
isSignedIn(): boolean
bypassChecksAndSignInWithRootKey(
email: string,
rootKey: RootKeyInterface,
@@ -42,4 +46,8 @@ export interface SessionsClientInterface {
rootKey: SNRootKey
wrappingKey?: SNRootKey
}): Promise<void>
getPublicKey(): string
getSigningPublicKey(): string
isUserMissingKeyPair(): boolean
}

View File

@@ -0,0 +1,9 @@
import { PkcKeyPair } from '@standardnotes/sncrypto-common'
export type UserKeyPairChangedEventData = {
oldKeyPair: PkcKeyPair | undefined
oldSigningKeyPair: PkcKeyPair | undefined
newKeyPair: PkcKeyPair
newSigningKeyPair: PkcKeyPair
}

View File

@@ -0,0 +1,8 @@
import { AsymmetricMessageSharedVaultInvite } from '@standardnotes/models'
import { SharedVaultInviteServerHash } from '@standardnotes/responses'
export type PendingSharedVaultInviteRecord = {
invite: SharedVaultInviteServerHash
message: AsymmetricMessageSharedVaultInvite
trusted: boolean
}

View File

@@ -0,0 +1,587 @@
import { MutatorClientInterface } from './../Mutator/MutatorClientInterface'
import { StorageServiceInterface } from './../Storage/StorageServiceInterface'
import { InviteContactToSharedVaultUseCase } from './UseCase/InviteContactToSharedVault'
import {
ClientDisplayableError,
SharedVaultInviteServerHash,
isErrorResponse,
SharedVaultUserServerHash,
isClientDisplayableError,
SharedVaultPermission,
UserEventType,
} from '@standardnotes/responses'
import {
HttpServiceInterface,
SharedVaultServerInterface,
SharedVaultUsersServerInterface,
SharedVaultInvitesServerInterface,
SharedVaultUsersServer,
SharedVaultInvitesServer,
SharedVaultServer,
AsymmetricMessageServerInterface,
AsymmetricMessageServer,
} from '@standardnotes/api'
import {
DecryptedItemInterface,
PayloadEmitSource,
TrustedContactInterface,
SharedVaultListingInterface,
VaultListingInterface,
AsymmetricMessageSharedVaultInvite,
KeySystemRootKeyStorageMode,
} from '@standardnotes/models'
import { SharedVaultServiceInterface } from './SharedVaultServiceInterface'
import { SharedVaultServiceEvent, SharedVaultServiceEventPayload } from './SharedVaultServiceEvent'
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import { ContentType } from '@standardnotes/common'
import { GetSharedVaultUsersUseCase } from './UseCase/GetSharedVaultUsers'
import { RemoveVaultMemberUseCase } from './UseCase/RemoveSharedVaultMember'
import { AbstractService } from '../Service/AbstractService'
import { InternalEventHandlerInterface } from '../Internal/InternalEventHandlerInterface'
import { SyncServiceInterface } from '../Sync/SyncServiceInterface'
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
import { SessionsClientInterface } from '../Session/SessionsClientInterface'
import { ContactServiceInterface } from '../Contacts/ContactServiceInterface'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { SyncEvent, SyncEventReceivedSharedVaultInvitesData } from '../Event/SyncEvent'
import { SessionEvent } from '../Session/SessionEvent'
import { InternalEventInterface } from '../Internal/InternalEventInterface'
import { FilesClientInterface } from '@standardnotes/files'
import { LeaveVaultUseCase } from './UseCase/LeaveSharedVault'
import { VaultServiceInterface } from '../Vaults/VaultServiceInterface'
import { UserEventServiceEvent, UserEventServiceEventPayload } from '../UserEvent/UserEventServiceEvent'
import { DeleteExternalSharedVaultUseCase } from './UseCase/DeleteExternalSharedVault'
import { DeleteSharedVaultUseCase } from './UseCase/DeleteSharedVault'
import { VaultServiceEvent, VaultServiceEventPayload } from '../Vaults/VaultServiceEvent'
import { AcceptTrustedSharedVaultInvite } from './UseCase/AcceptTrustedSharedVaultInvite'
import { GetAsymmetricMessageTrustedPayload } from '../AsymmetricMessage/UseCase/GetAsymmetricMessageTrustedPayload'
import { PendingSharedVaultInviteRecord } from './PendingSharedVaultInviteRecord'
import { GetAsymmetricMessageUntrustedPayload } from '../AsymmetricMessage/UseCase/GetAsymmetricMessageUntrustedPayload'
import { ShareContactWithAllMembersOfSharedVaultUseCase } from './UseCase/ShareContactWithAllMembersOfSharedVault'
import { GetSharedVaultTrustedContacts } from './UseCase/GetSharedVaultTrustedContacts'
import { NotifySharedVaultUsersOfRootKeyRotationUseCase } from './UseCase/NotifySharedVaultUsersOfRootKeyRotation'
import { CreateSharedVaultUseCase } from './UseCase/CreateSharedVault'
import { SendSharedVaultMetadataChangedMessageToAll } from './UseCase/SendSharedVaultMetadataChangedMessageToAll'
import { ConvertToSharedVaultUseCase } from './UseCase/ConvertToSharedVault'
import { GetVaultUseCase } from '../Vaults/UseCase/GetVault'
export class SharedVaultService
extends AbstractService<SharedVaultServiceEvent, SharedVaultServiceEventPayload>
implements SharedVaultServiceInterface, InternalEventHandlerInterface
{
private server: SharedVaultServerInterface
private usersServer: SharedVaultUsersServerInterface
private invitesServer: SharedVaultInvitesServerInterface
private messageServer: AsymmetricMessageServerInterface
private pendingInvites: Record<string, PendingSharedVaultInviteRecord> = {}
constructor(
http: HttpServiceInterface,
private sync: SyncServiceInterface,
private items: ItemManagerInterface,
private mutator: MutatorClientInterface,
private encryption: EncryptionProviderInterface,
private session: SessionsClientInterface,
private contacts: ContactServiceInterface,
private files: FilesClientInterface,
private vaults: VaultServiceInterface,
private storage: StorageServiceInterface,
eventBus: InternalEventBusInterface,
) {
super(eventBus)
eventBus.addEventHandler(this, SessionEvent.UserKeyPairChanged)
eventBus.addEventHandler(this, UserEventServiceEvent.UserEventReceived)
eventBus.addEventHandler(this, VaultServiceEvent.VaultRootKeyRotated)
this.server = new SharedVaultServer(http)
this.usersServer = new SharedVaultUsersServer(http)
this.invitesServer = new SharedVaultInvitesServer(http)
this.messageServer = new AsymmetricMessageServer(http)
this.eventDisposers.push(
sync.addEventObserver(async (event, data) => {
if (event === SyncEvent.ReceivedSharedVaultInvites) {
void this.processInboundInvites(data as SyncEventReceivedSharedVaultInvitesData)
} else if (event === SyncEvent.ReceivedRemoteSharedVaults) {
void this.notifyCollaborationStatusChanged()
}
}),
)
this.eventDisposers.push(
items.addObserver<TrustedContactInterface>(ContentType.TrustedContact, ({ changed, inserted, source }) => {
if (source === PayloadEmitSource.LocalChanged && inserted.length > 0) {
void this.handleCreationOfNewTrustedContacts(inserted)
}
if (source === PayloadEmitSource.LocalChanged && changed.length > 0) {
void this.handleTrustedContactsChange(changed)
}
}),
)
this.eventDisposers.push(
items.addObserver<VaultListingInterface>(ContentType.VaultListing, ({ changed, source }) => {
if (source === PayloadEmitSource.LocalChanged && changed.length > 0) {
void this.handleVaultListingsChange(changed)
}
}),
)
}
async handleEvent(event: InternalEventInterface): Promise<void> {
if (event.type === SessionEvent.UserKeyPairChanged) {
void this.invitesServer.deleteAllInboundInvites()
} else if (event.type === UserEventServiceEvent.UserEventReceived) {
await this.handleUserEvent(event.payload as UserEventServiceEventPayload)
} else if (event.type === VaultServiceEvent.VaultRootKeyRotated) {
const payload = event.payload as VaultServiceEventPayload[VaultServiceEvent.VaultRootKeyRotated]
await this.handleVaultRootKeyRotatedEvent(payload.vault)
}
}
private async handleUserEvent(event: UserEventServiceEventPayload): Promise<void> {
if (event.eventPayload.eventType === UserEventType.RemovedFromSharedVault) {
const vault = new GetVaultUseCase(this.items).execute({ sharedVaultUuid: event.eventPayload.sharedVaultUuid })
if (vault) {
const useCase = new DeleteExternalSharedVaultUseCase(
this.items,
this.mutator,
this.encryption,
this.storage,
this.sync,
)
await useCase.execute(vault)
}
} else if (event.eventPayload.eventType === UserEventType.SharedVaultItemRemoved) {
const item = this.items.findItem(event.eventPayload.itemUuid)
if (item) {
this.items.removeItemsLocally([item])
}
}
}
private async handleVaultRootKeyRotatedEvent(vault: VaultListingInterface): Promise<void> {
if (!vault.isSharedVaultListing()) {
return
}
if (!this.isCurrentUserSharedVaultOwner(vault)) {
return
}
const usecase = new NotifySharedVaultUsersOfRootKeyRotationUseCase(
this.usersServer,
this.invitesServer,
this.messageServer,
this.encryption,
this.contacts,
)
await usecase.execute({ sharedVault: vault, userUuid: this.session.getSureUser().uuid })
}
async createSharedVault(dto: {
name: string
description?: string
userInputtedPassword: string | undefined
storagePreference?: KeySystemRootKeyStorageMode
}): Promise<VaultListingInterface | ClientDisplayableError> {
const usecase = new CreateSharedVaultUseCase(
this.encryption,
this.items,
this.mutator,
this.sync,
this.files,
this.server,
)
return usecase.execute({
vaultName: dto.name,
vaultDescription: dto.description,
userInputtedPassword: dto.userInputtedPassword,
storagePreference: dto.storagePreference ?? KeySystemRootKeyStorageMode.Synced,
})
}
async convertVaultToSharedVault(
vault: VaultListingInterface,
): Promise<SharedVaultListingInterface | ClientDisplayableError> {
const usecase = new ConvertToSharedVaultUseCase(this.items, this.mutator, this.sync, this.files, this.server)
return usecase.execute({ vault })
}
public getCachedPendingInviteRecords(): PendingSharedVaultInviteRecord[] {
return Object.values(this.pendingInvites)
}
private getAllSharedVaults(): SharedVaultListingInterface[] {
const vaults = this.vaults.getVaults().filter((vault) => vault.isSharedVaultListing())
return vaults as SharedVaultListingInterface[]
}
private findSharedVault(sharedVaultUuid: string): SharedVaultListingInterface | undefined {
return this.getAllSharedVaults().find((vault) => vault.sharing.sharedVaultUuid === sharedVaultUuid)
}
public isCurrentUserSharedVaultAdmin(sharedVault: SharedVaultListingInterface): boolean {
if (!sharedVault.sharing.ownerUserUuid) {
throw new Error(`Shared vault ${sharedVault.sharing.sharedVaultUuid} does not have an owner user uuid`)
}
return sharedVault.sharing.ownerUserUuid === this.session.userUuid
}
public isCurrentUserSharedVaultOwner(sharedVault: SharedVaultListingInterface): boolean {
if (!sharedVault.sharing.ownerUserUuid) {
throw new Error(`Shared vault ${sharedVault.sharing.sharedVaultUuid} does not have an owner user uuid`)
}
return sharedVault.sharing.ownerUserUuid === this.session.userUuid
}
public isSharedVaultUserSharedVaultOwner(user: SharedVaultUserServerHash): boolean {
const vault = this.findSharedVault(user.shared_vault_uuid)
return vault != undefined && vault.sharing.ownerUserUuid === user.user_uuid
}
private async handleCreationOfNewTrustedContacts(_contacts: TrustedContactInterface[]): Promise<void> {
await this.downloadInboundInvites()
}
private async handleTrustedContactsChange(contacts: TrustedContactInterface[]): Promise<void> {
await this.reprocessCachedInvitesTrustStatusAfterTrustedContactsChange()
for (const contact of contacts) {
await this.shareContactWithUserAdministeredSharedVaults(contact)
}
}
private async handleVaultListingsChange(vaults: VaultListingInterface[]): Promise<void> {
for (const vault of vaults) {
if (!vault.isSharedVaultListing()) {
continue
}
const usecase = new SendSharedVaultMetadataChangedMessageToAll(
this.encryption,
this.contacts,
this.usersServer,
this.messageServer,
)
await usecase.execute({
vault,
senderUuid: this.session.getSureUser().uuid,
senderEncryptionKeyPair: this.encryption.getKeyPair(),
senderSigningKeyPair: this.encryption.getSigningKeyPair(),
})
}
}
public async downloadInboundInvites(): Promise<ClientDisplayableError | SharedVaultInviteServerHash[]> {
const response = await this.invitesServer.getInboundUserInvites()
if (isErrorResponse(response)) {
return ClientDisplayableError.FromString(`Failed to get inbound user invites ${response}`)
}
this.pendingInvites = {}
await this.processInboundInvites(response.data.invites)
return response.data.invites
}
public async getOutboundInvites(
sharedVault?: SharedVaultListingInterface,
): Promise<SharedVaultInviteServerHash[] | ClientDisplayableError> {
const response = await this.invitesServer.getOutboundUserInvites()
if (isErrorResponse(response)) {
return ClientDisplayableError.FromString(`Failed to get outbound user invites ${response}`)
}
if (sharedVault) {
return response.data.invites.filter((invite) => invite.shared_vault_uuid === sharedVault.sharing.sharedVaultUuid)
}
return response.data.invites
}
public async deleteInvite(invite: SharedVaultInviteServerHash): Promise<ClientDisplayableError | void> {
const response = await this.invitesServer.deleteInvite({
sharedVaultUuid: invite.shared_vault_uuid,
inviteUuid: invite.uuid,
})
if (isErrorResponse(response)) {
return ClientDisplayableError.FromString(`Failed to delete invite ${response}`)
}
delete this.pendingInvites[invite.uuid]
}
public async deleteSharedVault(sharedVault: SharedVaultListingInterface): Promise<ClientDisplayableError | void> {
const useCase = new DeleteSharedVaultUseCase(this.server, this.items, this.mutator, this.sync, this.encryption)
return useCase.execute({ sharedVault })
}
private async reprocessCachedInvitesTrustStatusAfterTrustedContactsChange(): Promise<void> {
const cachedInvites = this.getCachedPendingInviteRecords()
for (const record of cachedInvites) {
if (record.trusted) {
continue
}
const trustedMessageUseCase = new GetAsymmetricMessageTrustedPayload<AsymmetricMessageSharedVaultInvite>(
this.encryption,
this.contacts,
)
const trustedMessage = trustedMessageUseCase.execute({
message: record.invite,
privateKey: this.encryption.getKeyPair().privateKey,
})
if (trustedMessage) {
record.message = trustedMessage
record.trusted = true
}
}
}
private async processInboundInvites(invites: SharedVaultInviteServerHash[]): Promise<void> {
if (invites.length === 0) {
return
}
for (const invite of invites) {
const trustedMessageUseCase = new GetAsymmetricMessageTrustedPayload<AsymmetricMessageSharedVaultInvite>(
this.encryption,
this.contacts,
)
const trustedMessage = trustedMessageUseCase.execute({
message: invite,
privateKey: this.encryption.getKeyPair().privateKey,
})
if (trustedMessage) {
this.pendingInvites[invite.uuid] = {
invite,
message: trustedMessage,
trusted: true,
}
continue
}
const untrustedMessageUseCase = new GetAsymmetricMessageUntrustedPayload<AsymmetricMessageSharedVaultInvite>(
this.encryption,
)
const untrustedMessage = untrustedMessageUseCase.execute({
message: invite,
privateKey: this.encryption.getKeyPair().privateKey,
})
if (untrustedMessage) {
this.pendingInvites[invite.uuid] = {
invite,
message: untrustedMessage,
trusted: false,
}
}
}
await this.notifyCollaborationStatusChanged()
}
private async notifyCollaborationStatusChanged(): Promise<void> {
await this.notifyEventSync(SharedVaultServiceEvent.SharedVaultStatusChanged)
}
async acceptPendingSharedVaultInvite(pendingInvite: PendingSharedVaultInviteRecord): Promise<void> {
if (!pendingInvite.trusted) {
throw new Error('Cannot accept untrusted invite')
}
const useCase = new AcceptTrustedSharedVaultInvite(this.invitesServer, this.mutator, this.sync, this.contacts)
await useCase.execute({ invite: pendingInvite.invite, message: pendingInvite.message })
delete this.pendingInvites[pendingInvite.invite.uuid]
void this.sync.sync()
await this.decryptErroredItemsAfterInviteAccept()
await this.sync.syncSharedVaultsFromScratch([pendingInvite.invite.shared_vault_uuid])
}
private async decryptErroredItemsAfterInviteAccept(): Promise<void> {
await this.encryption.decryptErroredPayloads()
}
public async getInvitableContactsForSharedVault(
sharedVault: SharedVaultListingInterface,
): Promise<TrustedContactInterface[]> {
const users = await this.getSharedVaultUsers(sharedVault)
if (!users) {
return []
}
const contacts = this.contacts.getAllContacts()
return contacts.filter((contact) => {
const isContactAlreadyInVault = users.some((user) => user.user_uuid === contact.contactUuid)
return !isContactAlreadyInVault
})
}
private async getSharedVaultContacts(sharedVault: SharedVaultListingInterface): Promise<TrustedContactInterface[]> {
const usecase = new GetSharedVaultTrustedContacts(this.contacts, this.usersServer)
const contacts = await usecase.execute(sharedVault)
if (!contacts) {
return []
}
return contacts
}
async inviteContactToSharedVault(
sharedVault: SharedVaultListingInterface,
contact: TrustedContactInterface,
permissions: SharedVaultPermission,
): Promise<SharedVaultInviteServerHash | ClientDisplayableError> {
const sharedVaultContacts = await this.getSharedVaultContacts(sharedVault)
const useCase = new InviteContactToSharedVaultUseCase(this.encryption, this.invitesServer)
const result = await useCase.execute({
senderKeyPair: this.encryption.getKeyPair(),
senderSigningKeyPair: this.encryption.getSigningKeyPair(),
sharedVault,
recipient: contact,
sharedVaultContacts,
permissions,
})
void this.notifyCollaborationStatusChanged()
await this.sync.sync()
return result
}
async removeUserFromSharedVault(
sharedVault: SharedVaultListingInterface,
userUuid: string,
): Promise<ClientDisplayableError | void> {
if (!this.isCurrentUserSharedVaultAdmin(sharedVault)) {
throw new Error('Only vault admins can remove users')
}
if (this.vaults.isVaultLocked(sharedVault)) {
throw new Error('Cannot remove user from locked vault')
}
const useCase = new RemoveVaultMemberUseCase(this.usersServer)
const result = await useCase.execute({ sharedVaultUuid: sharedVault.sharing.sharedVaultUuid, userUuid })
if (isClientDisplayableError(result)) {
return result
}
void this.notifyCollaborationStatusChanged()
await this.vaults.rotateVaultRootKey(sharedVault)
}
async leaveSharedVault(sharedVault: SharedVaultListingInterface): Promise<ClientDisplayableError | void> {
const useCase = new LeaveVaultUseCase(
this.usersServer,
this.items,
this.mutator,
this.encryption,
this.storage,
this.sync,
)
const result = await useCase.execute({
sharedVault: sharedVault,
userUuid: this.session.getSureUser().uuid,
})
if (isClientDisplayableError(result)) {
return result
}
void this.notifyCollaborationStatusChanged()
}
async getSharedVaultUsers(
sharedVault: SharedVaultListingInterface,
): Promise<SharedVaultUserServerHash[] | undefined> {
const useCase = new GetSharedVaultUsersUseCase(this.usersServer)
return useCase.execute({ sharedVaultUuid: sharedVault.sharing.sharedVaultUuid })
}
private async shareContactWithUserAdministeredSharedVaults(contact: TrustedContactInterface): Promise<void> {
const sharedVaults = this.getAllSharedVaults()
const useCase = new ShareContactWithAllMembersOfSharedVaultUseCase(
this.contacts,
this.encryption,
this.usersServer,
this.messageServer,
)
for (const vault of sharedVaults) {
if (!this.isCurrentUserSharedVaultAdmin(vault)) {
continue
}
await useCase.execute({
senderKeyPair: this.encryption.getKeyPair(),
senderSigningKeyPair: this.encryption.getSigningKeyPair(),
sharedVault: vault,
contactToShare: contact,
senderUserUuid: this.session.getSureUser().uuid,
})
}
}
getItemLastEditedBy(item: DecryptedItemInterface): TrustedContactInterface | undefined {
if (!item.last_edited_by_uuid) {
return undefined
}
const contact = this.contacts.findTrustedContact(item.last_edited_by_uuid)
return contact
}
getItemSharedBy(item: DecryptedItemInterface): TrustedContactInterface | undefined {
if (!item.user_uuid || item.user_uuid === this.session.getSureUser().uuid) {
return undefined
}
const contact = this.contacts.findTrustedContact(item.user_uuid)
return contact
}
override deinit(): void {
super.deinit()
;(this.contacts as unknown) = undefined
;(this.encryption as unknown) = undefined
;(this.files as unknown) = undefined
;(this.invitesServer as unknown) = undefined
;(this.items as unknown) = undefined
;(this.messageServer as unknown) = undefined
;(this.server as unknown) = undefined
;(this.session as unknown) = undefined
;(this.sync as unknown) = undefined
;(this.usersServer as unknown) = undefined
;(this.vaults as unknown) = undefined
}
}

View File

@@ -0,0 +1,10 @@
import { KeySystemIdentifier } from '@standardnotes/models'
export enum SharedVaultServiceEvent {
SharedVaultStatusChanged = 'SharedVaultStatusChanged',
}
export type SharedVaultServiceEventPayload = {
sharedVaultUuid: string
keySystemIdentifier: KeySystemIdentifier
}

View File

@@ -0,0 +1,55 @@
import {
ClientDisplayableError,
SharedVaultInviteServerHash,
SharedVaultUserServerHash,
SharedVaultPermission,
} from '@standardnotes/responses'
import {
DecryptedItemInterface,
TrustedContactInterface,
SharedVaultListingInterface,
VaultListingInterface,
KeySystemRootKeyStorageMode,
} from '@standardnotes/models'
import { AbstractService } from '../Service/AbstractService'
import { SharedVaultServiceEvent, SharedVaultServiceEventPayload } from './SharedVaultServiceEvent'
import { PendingSharedVaultInviteRecord } from './PendingSharedVaultInviteRecord'
export interface SharedVaultServiceInterface
extends AbstractService<SharedVaultServiceEvent, SharedVaultServiceEventPayload> {
createSharedVault(dto: {
name: string
description?: string
userInputtedPassword: string | undefined
storagePreference?: KeySystemRootKeyStorageMode
}): Promise<VaultListingInterface | ClientDisplayableError>
deleteSharedVault(sharedVault: SharedVaultListingInterface): Promise<ClientDisplayableError | void>
convertVaultToSharedVault(vault: VaultListingInterface): Promise<SharedVaultListingInterface | ClientDisplayableError>
inviteContactToSharedVault(
sharedVault: SharedVaultListingInterface,
contact: TrustedContactInterface,
permissions: SharedVaultPermission,
): Promise<SharedVaultInviteServerHash | ClientDisplayableError>
removeUserFromSharedVault(
sharedVault: SharedVaultListingInterface,
userUuid: string,
): Promise<ClientDisplayableError | void>
leaveSharedVault(sharedVault: SharedVaultListingInterface): Promise<ClientDisplayableError | void>
getSharedVaultUsers(sharedVault: SharedVaultListingInterface): Promise<SharedVaultUserServerHash[] | undefined>
isSharedVaultUserSharedVaultOwner(user: SharedVaultUserServerHash): boolean
isCurrentUserSharedVaultAdmin(sharedVault: SharedVaultListingInterface): boolean
getItemLastEditedBy(item: DecryptedItemInterface): TrustedContactInterface | undefined
getItemSharedBy(item: DecryptedItemInterface): TrustedContactInterface | undefined
downloadInboundInvites(): Promise<ClientDisplayableError | SharedVaultInviteServerHash[]>
getOutboundInvites(
sharedVault?: SharedVaultListingInterface,
): Promise<SharedVaultInviteServerHash[] | ClientDisplayableError>
acceptPendingSharedVaultInvite(pendingInvite: PendingSharedVaultInviteRecord): Promise<void>
getCachedPendingInviteRecords(): PendingSharedVaultInviteRecord[]
getInvitableContactsForSharedVault(sharedVault: SharedVaultListingInterface): Promise<TrustedContactInterface[]>
deleteInvite(invite: SharedVaultInviteServerHash): Promise<ClientDisplayableError | void>
}

View File

@@ -0,0 +1,29 @@
import { MutatorClientInterface } from './../../Mutator/MutatorClientInterface'
import { SyncServiceInterface } from './../../Sync/SyncServiceInterface'
import { AsymmetricMessageSharedVaultInvite } from '@standardnotes/models'
import { SharedVaultInvitesServerInterface } from '@standardnotes/api'
import { SharedVaultInviteServerHash } from '@standardnotes/responses'
import { HandleTrustedSharedVaultInviteMessage } from '../../AsymmetricMessage/UseCase/HandleTrustedSharedVaultInviteMessage'
import { ContactServiceInterface } from '../../Contacts/ContactServiceInterface'
export class AcceptTrustedSharedVaultInvite {
constructor(
private vaultInvitesServer: SharedVaultInvitesServerInterface,
private mutator: MutatorClientInterface,
private sync: SyncServiceInterface,
private contacts: ContactServiceInterface,
) {}
async execute(dto: {
invite: SharedVaultInviteServerHash
message: AsymmetricMessageSharedVaultInvite
}): Promise<void> {
const useCase = new HandleTrustedSharedVaultInviteMessage(this.mutator, this.sync, this.contacts)
await useCase.execute(dto.message, dto.invite.shared_vault_uuid, dto.invite.sender_uuid)
await this.vaultInvitesServer.acceptInvite({
sharedVaultUuid: dto.invite.shared_vault_uuid,
inviteUuid: dto.invite.uuid,
})
}
}

View File

@@ -0,0 +1,47 @@
import { SyncServiceInterface } from './../../Sync/SyncServiceInterface'
import { SharedVaultListingInterface, VaultListingInterface, VaultListingMutator } from '@standardnotes/models'
import { ClientDisplayableError, isErrorResponse } from '@standardnotes/responses'
import { SharedVaultServerInterface } from '@standardnotes/api'
import { ItemManagerInterface } from '../../Item/ItemManagerInterface'
import { MoveItemsToVaultUseCase } from '../../Vaults/UseCase/MoveItemsToVault'
import { FilesClientInterface } from '@standardnotes/files'
import { MutatorClientInterface } from '../../Mutator/MutatorClientInterface'
export class ConvertToSharedVaultUseCase {
constructor(
private items: ItemManagerInterface,
private mutator: MutatorClientInterface,
private sync: SyncServiceInterface,
private files: FilesClientInterface,
private sharedVaultServer: SharedVaultServerInterface,
) {}
async execute(dto: { vault: VaultListingInterface }): Promise<SharedVaultListingInterface | ClientDisplayableError> {
if (dto.vault.isSharedVaultListing()) {
throw new Error('Cannot convert a shared vault to a shared vault')
}
const serverResult = await this.sharedVaultServer.createSharedVault()
if (isErrorResponse(serverResult)) {
return ClientDisplayableError.FromString(`Failed to create shared vault ${serverResult}`)
}
const serverVaultHash = serverResult.data.sharedVault
const sharedVaultListing = await this.mutator.changeItem<VaultListingMutator, VaultListingInterface>(
dto.vault,
(mutator) => {
mutator.sharing = {
sharedVaultUuid: serverVaultHash.uuid,
ownerUserUuid: serverVaultHash.user_uuid,
}
},
)
const vaultItems = this.items.itemsBelongingToKeySystem(sharedVaultListing.systemIdentifier)
const moveToVaultUsecase = new MoveItemsToVaultUseCase(this.mutator, this.sync, this.files)
await moveToVaultUsecase.execute({ vault: sharedVaultListing, items: vaultItems })
return sharedVaultListing as SharedVaultListingInterface
}
}

View File

@@ -0,0 +1,64 @@
import { SyncServiceInterface } from './../../Sync/SyncServiceInterface'
import {
KeySystemRootKeyStorageMode,
SharedVaultListingInterface,
VaultListingInterface,
VaultListingMutator,
} from '@standardnotes/models'
import { ClientDisplayableError, isErrorResponse } from '@standardnotes/responses'
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import { SharedVaultServerInterface } from '@standardnotes/api'
import { ItemManagerInterface } from '../../Item/ItemManagerInterface'
import { CreateVaultUseCase } from '../../Vaults/UseCase/CreateVault'
import { MoveItemsToVaultUseCase } from '../../Vaults/UseCase/MoveItemsToVault'
import { FilesClientInterface } from '@standardnotes/files'
import { MutatorClientInterface } from '../../Mutator/MutatorClientInterface'
export class CreateSharedVaultUseCase {
constructor(
private encryption: EncryptionProviderInterface,
private items: ItemManagerInterface,
private mutator: MutatorClientInterface,
private sync: SyncServiceInterface,
private files: FilesClientInterface,
private sharedVaultServer: SharedVaultServerInterface,
) {}
async execute(dto: {
vaultName: string
vaultDescription?: string
userInputtedPassword: string | undefined
storagePreference: KeySystemRootKeyStorageMode
}): Promise<SharedVaultListingInterface | ClientDisplayableError> {
const usecase = new CreateVaultUseCase(this.mutator, this.encryption, this.sync)
const privateVault = await usecase.execute({
vaultName: dto.vaultName,
vaultDescription: dto.vaultDescription,
userInputtedPassword: dto.userInputtedPassword,
storagePreference: dto.storagePreference,
})
const serverResult = await this.sharedVaultServer.createSharedVault()
if (isErrorResponse(serverResult)) {
return ClientDisplayableError.FromString(`Failed to create shared vault ${JSON.stringify(serverResult)}`)
}
const serverVaultHash = serverResult.data.sharedVault
const sharedVaultListing = await this.mutator.changeItem<VaultListingMutator, VaultListingInterface>(
privateVault,
(mutator) => {
mutator.sharing = {
sharedVaultUuid: serverVaultHash.uuid,
ownerUserUuid: serverVaultHash.user_uuid,
}
},
)
const vaultItems = this.items.itemsBelongingToKeySystem(sharedVaultListing.systemIdentifier)
const moveToVaultUsecase = new MoveItemsToVaultUseCase(this.mutator, this.sync, this.files)
await moveToVaultUsecase.execute({ vault: sharedVaultListing, items: vaultItems })
return sharedVaultListing as SharedVaultListingInterface
}
}

View File

@@ -0,0 +1,48 @@
import { SyncServiceInterface } from './../../Sync/SyncServiceInterface'
import { StorageServiceInterface } from '../../Storage/StorageServiceInterface'
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import { ItemManagerInterface } from '../../Item/ItemManagerInterface'
import { AnyItemInterface, VaultListingInterface } from '@standardnotes/models'
import { Uuids } from '@standardnotes/utils'
import { MutatorClientInterface } from '../../Mutator/MutatorClientInterface'
export class DeleteExternalSharedVaultUseCase {
constructor(
private items: ItemManagerInterface,
private mutator: MutatorClientInterface,
private encryption: EncryptionProviderInterface,
private storage: StorageServiceInterface,
private sync: SyncServiceInterface,
) {}
async execute(vault: VaultListingInterface): Promise<void> {
await this.deleteDataSharedByVaultUsers(vault)
await this.deleteDataOwnedByThisUser(vault)
await this.encryption.keys.deleteNonPersistentSystemRootKeysForVault(vault.systemIdentifier)
void this.sync.sync({ sourceDescription: 'Not awaiting due to this event handler running from sync response' })
}
/**
* This data is shared with all vault users and does not belong to this particular user
* The data will be removed locally without syncing the items
*/
private async deleteDataSharedByVaultUsers(vault: VaultListingInterface): Promise<void> {
const vaultItems = this.items
.allTrackedItems()
.filter((item) => item.key_system_identifier === vault.systemIdentifier)
this.items.removeItemsLocally(vaultItems as AnyItemInterface[])
const itemsKeys = this.encryption.keys.getKeySystemItemsKeys(vault.systemIdentifier)
this.items.removeItemsLocally(itemsKeys)
await this.storage.deletePayloadsWithUuids([...Uuids(vaultItems), ...Uuids(itemsKeys)])
}
private async deleteDataOwnedByThisUser(vault: VaultListingInterface): Promise<void> {
const rootKeys = this.encryption.keys.getSyncedKeySystemRootKeysForVault(vault.systemIdentifier)
await this.mutator.setItemsToBeDeleted(rootKeys)
await this.mutator.setItemToBeDeleted(vault)
}
}

View File

@@ -0,0 +1,33 @@
import { MutatorClientInterface } from './../../Mutator/MutatorClientInterface'
import { ClientDisplayableError, isErrorResponse } from '@standardnotes/responses'
import { SharedVaultServerInterface } from '@standardnotes/api'
import { ItemManagerInterface } from '../../Item/ItemManagerInterface'
import { SharedVaultListingInterface } from '@standardnotes/models'
import { SyncServiceInterface } from '../../Sync/SyncServiceInterface'
import { DeleteVaultUseCase } from '../../Vaults/UseCase/DeleteVault'
import { EncryptionProviderInterface } from '@standardnotes/encryption'
export class DeleteSharedVaultUseCase {
constructor(
private sharedVaultServer: SharedVaultServerInterface,
private items: ItemManagerInterface,
private mutator: MutatorClientInterface,
private sync: SyncServiceInterface,
private encryption: EncryptionProviderInterface,
) {}
async execute(params: { sharedVault: SharedVaultListingInterface }): Promise<ClientDisplayableError | void> {
const response = await this.sharedVaultServer.deleteSharedVault({
sharedVaultUuid: params.sharedVault.sharing.sharedVaultUuid,
})
if (isErrorResponse(response)) {
return ClientDisplayableError.FromString(`Failed to delete vault ${response}`)
}
const deleteUsecase = new DeleteVaultUseCase(this.items, this.mutator, this.encryption)
await deleteUsecase.execute(params.sharedVault)
await this.sync.sync()
}
}

View File

@@ -0,0 +1,23 @@
import { SharedVaultUsersServerInterface } from '@standardnotes/api'
import { GetSharedVaultUsersUseCase } from './GetSharedVaultUsers'
import { SharedVaultListingInterface, TrustedContactInterface } from '@standardnotes/models'
import { ContactServiceInterface } from '../../Contacts/ContactServiceInterface'
import { isNotUndefined } from '@standardnotes/utils'
export class GetSharedVaultTrustedContacts {
constructor(
private contacts: ContactServiceInterface,
private sharedVaultUsersServer: SharedVaultUsersServerInterface,
) {}
async execute(vault: SharedVaultListingInterface): Promise<TrustedContactInterface[] | undefined> {
const useCase = new GetSharedVaultUsersUseCase(this.sharedVaultUsersServer)
const users = await useCase.execute({ sharedVaultUuid: vault.sharing.sharedVaultUuid })
if (!users) {
return undefined
}
const contacts = users.map((user) => this.contacts.findTrustedContact(user.user_uuid)).filter(isNotUndefined)
return contacts
}
}

View File

@@ -0,0 +1,16 @@
import { SharedVaultUserServerHash, isErrorResponse } from '@standardnotes/responses'
import { SharedVaultUsersServerInterface } from '@standardnotes/api'
export class GetSharedVaultUsersUseCase {
constructor(private vaultUsersServer: SharedVaultUsersServerInterface) {}
async execute(params: { sharedVaultUuid: string }): Promise<SharedVaultUserServerHash[] | undefined> {
const response = await this.vaultUsersServer.getSharedVaultUsers({ sharedVaultUuid: params.sharedVaultUuid })
if (isErrorResponse(response)) {
return undefined
}
return response.data.users
}
}

View File

@@ -0,0 +1,63 @@
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import { ClientDisplayableError, SharedVaultInviteServerHash, SharedVaultPermission } from '@standardnotes/responses'
import {
TrustedContactInterface,
SharedVaultListingInterface,
AsymmetricMessagePayloadType,
} from '@standardnotes/models'
import { SharedVaultInvitesServerInterface } from '@standardnotes/api'
import { SendSharedVaultInviteUseCase } from './SendSharedVaultInviteUseCase'
import { PkcKeyPair } from '@standardnotes/sncrypto-common'
export class InviteContactToSharedVaultUseCase {
constructor(
private encryption: EncryptionProviderInterface,
private sharedVaultInviteServer: SharedVaultInvitesServerInterface,
) {}
async execute(params: {
senderKeyPair: PkcKeyPair
senderSigningKeyPair: PkcKeyPair
sharedVault: SharedVaultListingInterface
sharedVaultContacts: TrustedContactInterface[]
recipient: TrustedContactInterface
permissions: SharedVaultPermission
}): Promise<SharedVaultInviteServerHash | ClientDisplayableError> {
const keySystemRootKey = this.encryption.keys.getPrimaryKeySystemRootKey(params.sharedVault.systemIdentifier)
if (!keySystemRootKey) {
return ClientDisplayableError.FromString('Cannot add contact; key system root key not found')
}
const delegatedContacts = params.sharedVaultContacts.filter(
(contact) => !contact.isMe && contact.contactUuid !== params.recipient.contactUuid,
)
const encryptedMessage = this.encryption.asymmetricallyEncryptMessage({
message: {
type: AsymmetricMessagePayloadType.SharedVaultInvite,
data: {
recipientUuid: params.recipient.contactUuid,
rootKey: keySystemRootKey.content,
trustedContacts: delegatedContacts.map((contact) => contact.content),
metadata: {
name: params.sharedVault.name,
description: params.sharedVault.description,
},
},
},
senderKeyPair: params.senderKeyPair,
senderSigningKeyPair: params.senderSigningKeyPair,
recipientPublicKey: params.recipient.publicKeySet.encryption,
})
const createInviteUseCase = new SendSharedVaultInviteUseCase(this.sharedVaultInviteServer)
const createInviteResult = await createInviteUseCase.execute({
sharedVaultUuid: params.sharedVault.sharing.sharedVaultUuid,
recipientUuid: params.recipient.contactUuid,
encryptedMessage,
permissions: params.permissions,
})
return createInviteResult
}
}

View File

@@ -0,0 +1,48 @@
import { MutatorClientInterface } from './../../Mutator/MutatorClientInterface'
import { SyncServiceInterface } from './../../Sync/SyncServiceInterface'
import { StorageServiceInterface } from './../../Storage/StorageServiceInterface'
import { ClientDisplayableError, isErrorResponse } from '@standardnotes/responses'
import { SharedVaultUsersServerInterface } from '@standardnotes/api'
import { DeleteExternalSharedVaultUseCase } from './DeleteExternalSharedVault'
import { ItemManagerInterface } from '../../Item/ItemManagerInterface'
import { SharedVaultListingInterface } from '@standardnotes/models'
import { EncryptionProviderInterface } from '@standardnotes/encryption'
export class LeaveVaultUseCase {
constructor(
private vaultUserServer: SharedVaultUsersServerInterface,
private items: ItemManagerInterface,
private mutator: MutatorClientInterface,
private encryption: EncryptionProviderInterface,
private storage: StorageServiceInterface,
private sync: SyncServiceInterface,
) {}
async execute(params: {
sharedVault: SharedVaultListingInterface
userUuid: string
}): Promise<ClientDisplayableError | void> {
const latestVaultListing = this.items.findItem<SharedVaultListingInterface>(params.sharedVault.uuid)
if (!latestVaultListing) {
throw new Error(`LeaveVaultUseCase: Could not find vault ${params.sharedVault.uuid}`)
}
const response = await this.vaultUserServer.deleteSharedVaultUser({
sharedVaultUuid: latestVaultListing.sharing.sharedVaultUuid,
userUuid: params.userUuid,
})
if (isErrorResponse(response)) {
return ClientDisplayableError.FromString(`Failed to leave vault ${JSON.stringify(response)}`)
}
const removeLocalItems = new DeleteExternalSharedVaultUseCase(
this.items,
this.mutator,
this.encryption,
this.storage,
this.sync,
)
await removeLocalItems.execute(latestVaultListing)
}
}

View File

@@ -0,0 +1,62 @@
import {
AsymmetricMessageServerInterface,
SharedVaultInvitesServerInterface,
SharedVaultUsersServerInterface,
} from '@standardnotes/api'
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import { ContactServiceInterface } from '../../Contacts/ContactServiceInterface'
import { SharedVaultListingInterface } from '@standardnotes/models'
import { ClientDisplayableError } from '@standardnotes/responses'
import { ReuploadSharedVaultInvitesAfterKeyRotationUseCase } from './ReuploadSharedVaultInvitesAfterKeyRotation'
import { SendSharedVaultRootKeyChangedMessageToAll } from './SendSharedVaultRootKeyChangedMessageToAll'
export class NotifySharedVaultUsersOfRootKeyRotationUseCase {
constructor(
private sharedVaultUsersServer: SharedVaultUsersServerInterface,
private sharedVaultInvitesServer: SharedVaultInvitesServerInterface,
private messageServer: AsymmetricMessageServerInterface,
private encryption: EncryptionProviderInterface,
private contacts: ContactServiceInterface,
) {}
async execute(params: {
sharedVault: SharedVaultListingInterface
userUuid: string
}): Promise<ClientDisplayableError[]> {
const errors: ClientDisplayableError[] = []
const updatePendingInvitesUseCase = new ReuploadSharedVaultInvitesAfterKeyRotationUseCase(
this.encryption,
this.contacts,
this.sharedVaultInvitesServer,
this.sharedVaultUsersServer,
)
const updateExistingResults = await updatePendingInvitesUseCase.execute({
sharedVault: params.sharedVault,
senderUuid: params.userUuid,
senderEncryptionKeyPair: this.encryption.getKeyPair(),
senderSigningKeyPair: this.encryption.getSigningKeyPair(),
})
errors.push(...updateExistingResults)
const shareKeyUseCase = new SendSharedVaultRootKeyChangedMessageToAll(
this.encryption,
this.contacts,
this.sharedVaultUsersServer,
this.messageServer,
)
const shareKeyResults = await shareKeyUseCase.execute({
keySystemIdentifier: params.sharedVault.systemIdentifier,
sharedVaultUuid: params.sharedVault.sharing.sharedVaultUuid,
senderUuid: params.userUuid,
senderEncryptionKeyPair: this.encryption.getKeyPair(),
senderSigningKeyPair: this.encryption.getSigningKeyPair(),
})
errors.push(...shareKeyResults)
return errors
}
}

View File

@@ -0,0 +1,17 @@
import { ClientDisplayableError, isErrorResponse } from '@standardnotes/responses'
import { SharedVaultUsersServerInterface } from '@standardnotes/api'
export class RemoveVaultMemberUseCase {
constructor(private vaultUserServer: SharedVaultUsersServerInterface) {}
async execute(params: { sharedVaultUuid: string; userUuid: string }): Promise<ClientDisplayableError | void> {
const response = await this.vaultUserServer.deleteSharedVaultUser({
sharedVaultUuid: params.sharedVaultUuid,
userUuid: params.userUuid,
})
if (isErrorResponse(response)) {
return ClientDisplayableError.FromNetworkError(response)
}
}
}

View File

@@ -0,0 +1,144 @@
import {
KeySystemRootKeyContentSpecialized,
SharedVaultListingInterface,
TrustedContactInterface,
} from '@standardnotes/models'
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import {
ClientDisplayableError,
SharedVaultInviteServerHash,
isClientDisplayableError,
isErrorResponse,
} from '@standardnotes/responses'
import { SharedVaultInvitesServerInterface, SharedVaultUsersServerInterface } from '@standardnotes/api'
import { ContactServiceInterface } from '../../Contacts/ContactServiceInterface'
import { PkcKeyPair } from '@standardnotes/sncrypto-common'
import { InviteContactToSharedVaultUseCase } from './InviteContactToSharedVault'
import { GetSharedVaultTrustedContacts } from './GetSharedVaultTrustedContacts'
type ReuploadAllSharedVaultInvitesDTO = {
sharedVault: SharedVaultListingInterface
senderUuid: string
senderEncryptionKeyPair: PkcKeyPair
senderSigningKeyPair: PkcKeyPair
}
export class ReuploadSharedVaultInvitesAfterKeyRotationUseCase {
constructor(
private encryption: EncryptionProviderInterface,
private contacts: ContactServiceInterface,
private vaultInvitesServer: SharedVaultInvitesServerInterface,
private vaultUserServer: SharedVaultUsersServerInterface,
) {}
async execute(params: ReuploadAllSharedVaultInvitesDTO): Promise<ClientDisplayableError[]> {
const keySystemRootKey = this.encryption.keys.getPrimaryKeySystemRootKey(params.sharedVault.systemIdentifier)
if (!keySystemRootKey) {
throw new Error(`Vault key not found for keySystemIdentifier ${params.sharedVault.systemIdentifier}`)
}
const existingInvites = await this.getExistingInvites(params.sharedVault.sharing.sharedVaultUuid)
if (isClientDisplayableError(existingInvites)) {
return [existingInvites]
}
const deleteResult = await this.deleteExistingInvites(params.sharedVault.sharing.sharedVaultUuid)
if (isClientDisplayableError(deleteResult)) {
return [deleteResult]
}
const vaultContacts = await this.getVaultContacts(params.sharedVault)
if (vaultContacts.length === 0) {
return []
}
const errors: ClientDisplayableError[] = []
for (const invite of existingInvites) {
const contact = this.contacts.findTrustedContact(invite.user_uuid)
if (!contact) {
errors.push(ClientDisplayableError.FromString(`Contact not found for invite ${invite.user_uuid}`))
continue
}
const result = await this.sendNewInvite({
usecaseDTO: params,
contact: contact,
previousInvite: invite,
keySystemRootKeyData: keySystemRootKey.content,
sharedVaultContacts: vaultContacts,
})
if (isClientDisplayableError(result)) {
errors.push(result)
}
}
return errors
}
private async getVaultContacts(sharedVault: SharedVaultListingInterface): Promise<TrustedContactInterface[]> {
const usecase = new GetSharedVaultTrustedContacts(this.contacts, this.vaultUserServer)
const contacts = await usecase.execute(sharedVault)
if (!contacts) {
return []
}
return contacts
}
private async getExistingInvites(
sharedVaultUuid: string,
): Promise<SharedVaultInviteServerHash[] | ClientDisplayableError> {
const response = await this.vaultInvitesServer.getOutboundUserInvites()
if (isErrorResponse(response)) {
return ClientDisplayableError.FromString(`Failed to get outbound user invites ${response}`)
}
const invites = response.data.invites
return invites.filter((invite) => invite.shared_vault_uuid === sharedVaultUuid)
}
private async deleteExistingInvites(sharedVaultUuid: string): Promise<ClientDisplayableError | void> {
const response = await this.vaultInvitesServer.deleteAllSharedVaultInvites({
sharedVaultUuid: sharedVaultUuid,
})
if (isErrorResponse(response)) {
return ClientDisplayableError.FromString(`Failed to delete existing invites ${response}`)
}
}
private async sendNewInvite(params: {
usecaseDTO: ReuploadAllSharedVaultInvitesDTO
contact: TrustedContactInterface
previousInvite: SharedVaultInviteServerHash
keySystemRootKeyData: KeySystemRootKeyContentSpecialized
sharedVaultContacts: TrustedContactInterface[]
}): Promise<ClientDisplayableError | void> {
const signatureResult = this.encryption.asymmetricSignatureVerifyDetached(params.previousInvite.encrypted_message)
if (!signatureResult.signatureVerified) {
return ClientDisplayableError.FromString('Failed to verify signature of previous invite')
}
if (signatureResult.signaturePublicKey !== params.usecaseDTO.senderSigningKeyPair.publicKey) {
return ClientDisplayableError.FromString('Sender public key does not match signature')
}
const usecase = new InviteContactToSharedVaultUseCase(this.encryption, this.vaultInvitesServer)
const result = await usecase.execute({
senderKeyPair: params.usecaseDTO.senderEncryptionKeyPair,
senderSigningKeyPair: params.usecaseDTO.senderSigningKeyPair,
sharedVault: params.usecaseDTO.sharedVault,
sharedVaultContacts: params.sharedVaultContacts,
recipient: params.contact,
permissions: params.previousInvite.permissions,
})
if (isClientDisplayableError(result)) {
return result
}
}
}

View File

@@ -0,0 +1,31 @@
import {
ClientDisplayableError,
SharedVaultInviteServerHash,
isErrorResponse,
SharedVaultPermission,
} from '@standardnotes/responses'
import { SharedVaultInvitesServerInterface } from '@standardnotes/api'
export class SendSharedVaultInviteUseCase {
constructor(private vaultInvitesServer: SharedVaultInvitesServerInterface) {}
async execute(params: {
sharedVaultUuid: string
recipientUuid: string
encryptedMessage: string
permissions: SharedVaultPermission
}): Promise<SharedVaultInviteServerHash | ClientDisplayableError> {
const response = await this.vaultInvitesServer.createInvite({
sharedVaultUuid: params.sharedVaultUuid,
recipientUuid: params.recipientUuid,
encryptedMessage: params.encryptedMessage,
permissions: params.permissions,
})
if (isErrorResponse(response)) {
return ClientDisplayableError.FromError(response.data.error)
}
return response.data.invite
}
}

View File

@@ -0,0 +1,100 @@
import {
AsymmetricMessagePayloadType,
AsymmetricMessageSharedVaultMetadataChanged,
SharedVaultListingInterface,
TrustedContactInterface,
} from '@standardnotes/models'
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import { AsymmetricMessageServerHash, ClientDisplayableError, isClientDisplayableError } from '@standardnotes/responses'
import { AsymmetricMessageServerInterface, SharedVaultUsersServerInterface } from '@standardnotes/api'
import { GetSharedVaultUsersUseCase } from './GetSharedVaultUsers'
import { ContactServiceInterface } from '../../Contacts/ContactServiceInterface'
import { PkcKeyPair } from '@standardnotes/sncrypto-common'
import { SendAsymmetricMessageUseCase } from '../../AsymmetricMessage/UseCase/SendAsymmetricMessageUseCase'
export class SendSharedVaultMetadataChangedMessageToAll {
constructor(
private encryption: EncryptionProviderInterface,
private contacts: ContactServiceInterface,
private vaultUsersServer: SharedVaultUsersServerInterface,
private messageServer: AsymmetricMessageServerInterface,
) {}
async execute(params: {
vault: SharedVaultListingInterface
senderUuid: string
senderEncryptionKeyPair: PkcKeyPair
senderSigningKeyPair: PkcKeyPair
}): Promise<ClientDisplayableError[]> {
const errors: ClientDisplayableError[] = []
const getUsersUseCase = new GetSharedVaultUsersUseCase(this.vaultUsersServer)
const users = await getUsersUseCase.execute({ sharedVaultUuid: params.vault.sharing.sharedVaultUuid })
if (!users) {
return [ClientDisplayableError.FromString('Cannot send metadata changed message; users not found')]
}
for (const user of users) {
if (user.user_uuid === params.senderUuid) {
continue
}
const trustedContact = this.contacts.findTrustedContact(user.user_uuid)
if (!trustedContact) {
continue
}
const sendMessageResult = await this.sendToContact({
vault: params.vault,
senderKeyPair: params.senderEncryptionKeyPair,
senderSigningKeyPair: params.senderSigningKeyPair,
contact: trustedContact,
})
if (isClientDisplayableError(sendMessageResult)) {
errors.push(sendMessageResult)
}
}
return errors
}
private async sendToContact(params: {
vault: SharedVaultListingInterface
senderKeyPair: PkcKeyPair
senderSigningKeyPair: PkcKeyPair
contact: TrustedContactInterface
}): Promise<AsymmetricMessageServerHash | ClientDisplayableError> {
const message: AsymmetricMessageSharedVaultMetadataChanged = {
type: AsymmetricMessagePayloadType.SharedVaultMetadataChanged,
data: {
recipientUuid: params.contact.contactUuid,
sharedVaultUuid: params.vault.sharing.sharedVaultUuid,
name: params.vault.name,
description: params.vault.description,
},
}
const encryptedMessage = this.encryption.asymmetricallyEncryptMessage({
message: message,
senderKeyPair: params.senderKeyPair,
senderSigningKeyPair: params.senderSigningKeyPair,
recipientPublicKey: params.contact.publicKeySet.encryption,
})
const replaceabilityIdentifier = [
AsymmetricMessagePayloadType.SharedVaultMetadataChanged,
params.vault.sharing.sharedVaultUuid,
params.vault.systemIdentifier,
].join(':')
const sendMessageUseCase = new SendAsymmetricMessageUseCase(this.messageServer)
const sendMessageResult = await sendMessageUseCase.execute({
recipientUuid: params.contact.contactUuid,
encryptedMessage,
replaceabilityIdentifier,
})
return sendMessageResult
}
}

View File

@@ -0,0 +1,103 @@
import {
AsymmetricMessagePayloadType,
AsymmetricMessageSharedVaultRootKeyChanged,
KeySystemIdentifier,
TrustedContactInterface,
} from '@standardnotes/models'
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import { AsymmetricMessageServerHash, ClientDisplayableError, isClientDisplayableError } from '@standardnotes/responses'
import { AsymmetricMessageServerInterface, SharedVaultUsersServerInterface } from '@standardnotes/api'
import { GetSharedVaultUsersUseCase } from './GetSharedVaultUsers'
import { ContactServiceInterface } from '../../Contacts/ContactServiceInterface'
import { PkcKeyPair } from '@standardnotes/sncrypto-common'
import { SendAsymmetricMessageUseCase } from '../../AsymmetricMessage/UseCase/SendAsymmetricMessageUseCase'
export class SendSharedVaultRootKeyChangedMessageToAll {
constructor(
private encryption: EncryptionProviderInterface,
private contacts: ContactServiceInterface,
private vaultUsersServer: SharedVaultUsersServerInterface,
private messageServer: AsymmetricMessageServerInterface,
) {}
async execute(params: {
keySystemIdentifier: KeySystemIdentifier
sharedVaultUuid: string
senderUuid: string
senderEncryptionKeyPair: PkcKeyPair
senderSigningKeyPair: PkcKeyPair
}): Promise<ClientDisplayableError[]> {
const errors: ClientDisplayableError[] = []
const getUsersUseCase = new GetSharedVaultUsersUseCase(this.vaultUsersServer)
const users = await getUsersUseCase.execute({ sharedVaultUuid: params.sharedVaultUuid })
if (!users) {
return [ClientDisplayableError.FromString('Cannot send root key changed message; users not found')]
}
for (const user of users) {
if (user.user_uuid === params.senderUuid) {
continue
}
const trustedContact = this.contacts.findTrustedContact(user.user_uuid)
if (!trustedContact) {
continue
}
const sendMessageResult = await this.sendToContact({
keySystemIdentifier: params.keySystemIdentifier,
sharedVaultUuid: params.sharedVaultUuid,
senderKeyPair: params.senderEncryptionKeyPair,
senderSigningKeyPair: params.senderSigningKeyPair,
contact: trustedContact,
})
if (isClientDisplayableError(sendMessageResult)) {
errors.push(sendMessageResult)
}
}
return errors
}
private async sendToContact(params: {
keySystemIdentifier: KeySystemIdentifier
sharedVaultUuid: string
senderKeyPair: PkcKeyPair
senderSigningKeyPair: PkcKeyPair
contact: TrustedContactInterface
}): Promise<AsymmetricMessageServerHash | ClientDisplayableError> {
const keySystemRootKey = this.encryption.keys.getPrimaryKeySystemRootKey(params.keySystemIdentifier)
if (!keySystemRootKey) {
throw new Error(`Vault key not found for keySystemIdentifier ${params.keySystemIdentifier}`)
}
const message: AsymmetricMessageSharedVaultRootKeyChanged = {
type: AsymmetricMessagePayloadType.SharedVaultRootKeyChanged,
data: { recipientUuid: params.contact.contactUuid, rootKey: keySystemRootKey.content },
}
const encryptedMessage = this.encryption.asymmetricallyEncryptMessage({
message: message,
senderKeyPair: params.senderKeyPair,
senderSigningKeyPair: params.senderSigningKeyPair,
recipientPublicKey: params.contact.publicKeySet.encryption,
})
const replaceabilityIdentifier = [
AsymmetricMessagePayloadType.SharedVaultRootKeyChanged,
params.sharedVaultUuid,
params.keySystemIdentifier,
].join(':')
const sendMessageUseCase = new SendAsymmetricMessageUseCase(this.messageServer)
const sendMessageResult = await sendMessageUseCase.execute({
recipientUuid: params.contact.contactUuid,
encryptedMessage,
replaceabilityIdentifier,
})
return sendMessageResult
}
}

View File

@@ -0,0 +1,78 @@
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import { ClientDisplayableError, isErrorResponse } from '@standardnotes/responses'
import {
TrustedContactInterface,
SharedVaultListingInterface,
AsymmetricMessagePayloadType,
} from '@standardnotes/models'
import { AsymmetricMessageServerInterface, SharedVaultUsersServerInterface } from '@standardnotes/api'
import { PkcKeyPair } from '@standardnotes/sncrypto-common'
import { ContactServiceInterface } from '../../Contacts/ContactServiceInterface'
import { SendAsymmetricMessageUseCase } from '../../AsymmetricMessage/UseCase/SendAsymmetricMessageUseCase'
export class ShareContactWithAllMembersOfSharedVaultUseCase {
constructor(
private contacts: ContactServiceInterface,
private encryption: EncryptionProviderInterface,
private sharedVaultUsersServer: SharedVaultUsersServerInterface,
private messageServer: AsymmetricMessageServerInterface,
) {}
async execute(params: {
senderKeyPair: PkcKeyPair
senderSigningKeyPair: PkcKeyPair
senderUserUuid: string
sharedVault: SharedVaultListingInterface
contactToShare: TrustedContactInterface
}): Promise<void | ClientDisplayableError> {
if (params.sharedVault.sharing.ownerUserUuid !== params.senderUserUuid) {
return ClientDisplayableError.FromString('Cannot share contact; user is not the owner of the shared vault')
}
const usersResponse = await this.sharedVaultUsersServer.getSharedVaultUsers({
sharedVaultUuid: params.sharedVault.sharing.sharedVaultUuid,
})
if (isErrorResponse(usersResponse)) {
return ClientDisplayableError.FromString('Cannot share contact; shared vault users not found')
}
const users = usersResponse.data.users
if (users.length === 0) {
return
}
const messageSendUseCase = new SendAsymmetricMessageUseCase(this.messageServer)
for (const vaultUser of users) {
if (vaultUser.user_uuid === params.senderUserUuid) {
continue
}
if (vaultUser.user_uuid === params.contactToShare.contactUuid) {
continue
}
const vaultUserAsContact = this.contacts.findTrustedContact(vaultUser.user_uuid)
if (!vaultUserAsContact) {
continue
}
const encryptedMessage = this.encryption.asymmetricallyEncryptMessage({
message: {
type: AsymmetricMessagePayloadType.ContactShare,
data: { recipientUuid: vaultUserAsContact.contactUuid, trustedContact: params.contactToShare.content },
},
senderKeyPair: params.senderKeyPair,
senderSigningKeyPair: params.senderSigningKeyPair,
recipientPublicKey: vaultUserAsContact.publicKeySet.encryption,
})
await messageSendUseCase.execute({
recipientUuid: vaultUserAsContact.contactUuid,
encryptedMessage,
replaceabilityIdentifier: undefined,
})
}
}
}

View File

@@ -0,0 +1,31 @@
import {
ClientDisplayableError,
SharedVaultInviteServerHash,
isErrorResponse,
SharedVaultPermission,
} from '@standardnotes/responses'
import { SharedVaultInvitesServerInterface } from '@standardnotes/api'
export class UpdateSharedVaultInviteUseCase {
constructor(private vaultInvitesServer: SharedVaultInvitesServerInterface) {}
async execute(params: {
sharedVaultUuid: string
inviteUuid: string
encryptedMessage: string
permissions: SharedVaultPermission
}): Promise<SharedVaultInviteServerHash | ClientDisplayableError> {
const response = await this.vaultInvitesServer.updateInvite({
sharedVaultUuid: params.sharedVaultUuid,
inviteUuid: params.inviteUuid,
encryptedMessage: params.encryptedMessage,
permissions: params.permissions,
})
if (isErrorResponse(response)) {
return ClientDisplayableError.FromError(response.data.error)
}
return response.data.invite
}
}

View File

@@ -0,0 +1,26 @@
import { DecryptedItemInterface, ItemContent, Predicate, PredicateInterface } from '@standardnotes/models'
import { ContentType } from '@standardnotes/common'
export interface SingletonManagerInterface {
findSingleton<T extends DecryptedItemInterface>(
contentType: ContentType,
predicate: PredicateInterface<T>,
): T | undefined
findOrCreateContentTypeSingleton<
C extends ItemContent = ItemContent,
T extends DecryptedItemInterface<C> = DecryptedItemInterface<C>,
>(
contentType: ContentType,
createContent: ItemContent,
): Promise<T>
findOrCreateSingleton<
C extends ItemContent = ItemContent,
T extends DecryptedItemInterface<C> = DecryptedItemInterface<C>,
>(
predicate: Predicate<T>,
contentType: ContentType,
createContent: ItemContent,
): Promise<T>
}

View File

@@ -47,6 +47,7 @@ export enum StorageKey {
PlaintextBackupsLocation = 'plaintext_backups_location',
FileBackupsEnabled = 'file_backups_enabled',
FileBackupsLocation = 'file_backups_location',
VaultSelectionOptions = 'vault_selection_options',
}
export enum NonwrappedStorageKey {

View File

@@ -8,14 +8,16 @@ import { StoragePersistencePolicies, StorageValueModes } from './StorageTypes'
export interface StorageServiceInterface {
getAllRawPayloads(): Promise<FullyFormedTransferPayload[]>
getAllKeys(mode?: StorageValueModes): string[]
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
setValue<T>(key: string, value: T, mode?: StorageValueModes): void
removeValue(key: string, mode?: StorageValueModes): Promise<void>
setPersistencePolicy(persistencePolicy: StoragePersistencePolicies): Promise<void>
clearAllData(): Promise<void>
forceDeletePayloads(payloads: FullyFormedPayloadInterface[]): Promise<void>
deletePayloads(payloads: FullyFormedPayloadInterface[]): Promise<void>
deletePayloadsWithUuids(uuids: string[]): Promise<void>
clearAllPayloads(): Promise<void>
}

View File

@@ -1,9 +1,6 @@
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.",
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

@@ -167,6 +167,8 @@ export const ChallengeStrings = {
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',
UnlockVault: (vaultName: string) => `Unlock ${vaultName}`,
EnterVaultPassword: 'Enter the password for this vault',
}
export const ErrorAlertStrings = {

View File

@@ -19,4 +19,10 @@ export type SyncOptions = {
* and before the sync request is network dispatched
*/
onPresyncSave?: () => void
/** If supplied, the sync will be exclusive to items in these sharedVaults */
sharedVaultUuids?: string[]
/** If true and sharedVaultUuids is present, excludes sending global syncToken as part of request */
syncSharedVaultsFromScratch?: boolean
}

View File

@@ -2,8 +2,10 @@
import { FullyFormedPayloadInterface } from '@standardnotes/models'
import { SyncOptions } from './SyncOptions'
import { AbstractService } from '../Service/AbstractService'
import { SyncEvent } from '../Event/SyncEvent'
export interface SyncServiceInterface {
export interface SyncServiceInterface extends AbstractService<SyncEvent> {
sync(options?: Partial<SyncOptions>): Promise<unknown>
resetSyncState(): void
markAllItemsAsNeedingSyncAndPersist(): Promise<void>
@@ -11,4 +13,5 @@ export interface SyncServiceInterface {
persistPayloads(payloads: FullyFormedPayloadInterface[]): Promise<void>
lockSyncing(): void
unlockSyncing(): void
syncSharedVaultsFromScratch(sharedVaultUuids: string[]): Promise<void>
}

View File

@@ -1,8 +1,48 @@
import { Base64String } from '@standardnotes/sncrypto-common'
import { UserRequestType } from '@standardnotes/common'
import { Either, UserRequestType } from '@standardnotes/common'
import { DeinitSource } from '../Application/DeinitSource'
import { UserRegistrationResponseBody } from '@standardnotes/api'
import { HttpError, HttpResponse, SignInResponse } from '@standardnotes/responses'
import { AbstractService } from '../Service/AbstractService'
export interface UserClientInterface {
export type CredentialsChangeFunctionResponse = { error?: HttpError }
export enum AccountEvent {
SignedInOrRegistered = 'SignedInOrRegistered',
SignedOut = 'SignedOut',
}
export interface SignedInOrRegisteredEventPayload {
ephemeral: boolean
mergeLocal: boolean
awaitSync: boolean
checkIntegrity: boolean
}
export interface SignedOutEventPayload {
source: DeinitSource
}
export interface AccountEventData {
payload: Either<SignedInOrRegisteredEventPayload, SignedOutEventPayload>
}
export interface UserClientInterface extends AbstractService<AccountEvent, AccountEventData> {
isSignedIn(): boolean
register(
email: string,
password: string,
ephemeral: boolean,
mergeLocal: boolean,
): Promise<UserRegistrationResponseBody>
signIn(
email: string,
password: string,
strict: boolean,
ephemeral: boolean,
mergeLocal: boolean,
awaitSync: boolean,
): Promise<HttpResponse<SignInResponse>>
deleteAccount(): Promise<{
error: boolean
message?: string
@@ -10,4 +50,9 @@ export interface UserClientInterface {
signOut(force?: boolean, source?: DeinitSource): Promise<void>
submitUserRequest(requestType: UserRequestType): Promise<boolean>
populateSessionFromDemoShareToken(token: Base64String): Promise<void>
updateAccountWithFirstTimeKeyPair(): Promise<{
success?: true
canceled?: true
error?: { message: string }
}>
}

View File

@@ -1,9 +1,15 @@
import { Base64String } from '@standardnotes/sncrypto-common'
import { EncryptionProviderInterface, SNRootKey, SNRootKeyParams } from '@standardnotes/encryption'
import { HttpResponse, SignInResponse, User, HttpError, isErrorResponse } from '@standardnotes/responses'
import { Either, KeyParamsOrigination, UserRequestType } from '@standardnotes/common'
import { HttpResponse, SignInResponse, User, isErrorResponse } from '@standardnotes/responses'
import { KeyParamsOrigination, UserRequestType } from '@standardnotes/common'
import { UuidGenerator } from '@standardnotes/utils'
import { UserApiServiceInterface, UserRegistrationResponseBody } from '@standardnotes/api'
import {
AccountEventData,
AccountEvent,
SignedInOrRegisteredEventPayload,
CredentialsChangeFunctionResponse,
} from '@standardnotes/services'
import * as Messages from '../Strings/Messages'
import { InfoStrings } from '../Strings/InfoStrings'
@@ -28,28 +34,6 @@ import { ProtectionsClientInterface } from '../Protection/ProtectionClientInterf
import { InternalEventHandlerInterface } from '../Internal/InternalEventHandlerInterface'
import { InternalEventInterface } from '../Internal/InternalEventInterface'
export type CredentialsChangeFunctionResponse = { error?: HttpError }
export enum AccountEvent {
SignedInOrRegistered = 'SignedInOrRegistered',
SignedOut = 'SignedOut',
}
export interface SignedInOrRegisteredEventPayload {
ephemeral: boolean
mergeLocal: boolean
awaitSync: boolean
checkIntegrity: boolean
}
export interface SignedOutEventPayload {
source: DeinitSource
}
export interface AccountEventData {
payload: Either<SignedInOrRegisteredEventPayload, SignedOutEventPayload>
}
export class UserService
extends AbstractService<AccountEvent, AccountEventData>
implements UserClientInterface, InternalEventHandlerInterface
@@ -125,6 +109,10 @@ export class UserService
;(this.userApiService as unknown) = undefined
}
isSignedIn(): boolean {
return this.sessionManager.isSignedIn()
}
/**
* @param mergeLocal Whether to merge existing offline data into account. If false,
* any pre-existing data will be fully deleted upon success.
@@ -352,6 +340,20 @@ export class UserService
}
}
async updateAccountWithFirstTimeKeyPair(): Promise<{
success?: true
canceled?: true
error?: { message: string }
}> {
if (!this.sessionManager.isUserMissingKeyPair()) {
throw Error('Cannot update account with first time keypair if user already has a keypair')
}
const result = await this.performProtocolUpgrade()
return result
}
public async performProtocolUpgrade(): Promise<{
success?: true
canceled?: true
@@ -524,7 +526,7 @@ export class UserService
private async rewriteItemsKeys(): Promise<void> {
const itemsKeys = this.itemManager.getDisplayableItemsKeys()
const payloads = itemsKeys.map((key) => key.payloadRepresentation())
await this.storageService.forceDeletePayloads(payloads)
await this.storageService.deletePayloads(payloads)
await this.syncService.persistPayloads(payloads)
}
@@ -571,7 +573,7 @@ export class UserService
const user = this.sessionManager.getUser() as User
const currentEmail = user.email
const rootKeys = await this.recomputeRootKeysForCredentialChange({
const { currentRootKey, newRootKey } = await this.recomputeRootKeysForCredentialChange({
currentPassword: parameters.currentPassword,
currentEmail,
origination: parameters.origination,
@@ -583,8 +585,8 @@ export class UserService
/** Now, change the credentials on the server. Roll back on failure */
const { response } = await this.sessionManager.changeCredentials({
currentServerPassword: rootKeys.currentRootKey.serverPassword as string,
newRootKey: rootKeys.newRootKey,
currentServerPassword: currentRootKey.serverPassword as string,
newRootKey: newRootKey,
wrappingKey,
newEmail: parameters.newEmail,
})
@@ -596,7 +598,7 @@ export class UserService
}
const rollback = await this.protocolService.createNewItemsKeyWithRollback()
await this.protocolService.reencryptItemsKeys()
await this.protocolService.reencryptApplicableItemsAfterUserRootKeyChange()
await this.syncService.sync({ awaitAll: true })
const defaultItemsKey = this.protocolService.getSureDefaultItemsKey()
@@ -604,11 +606,11 @@ export class UserService
if (!itemsKeyWasSynced) {
await this.sessionManager.changeCredentials({
currentServerPassword: rootKeys.newRootKey.serverPassword as string,
newRootKey: rootKeys.currentRootKey,
currentServerPassword: newRootKey.serverPassword as string,
newRootKey: currentRootKey,
wrappingKey,
})
await this.protocolService.reencryptItemsKeys()
await this.protocolService.reencryptApplicableItemsAfterUserRootKeyChange()
await rollback()
await this.syncService.sync({ awaitAll: true })

View File

@@ -0,0 +1,38 @@
import { UserEventServerHash } from '@standardnotes/responses'
import { SyncEvent, SyncEventReceivedUserEventsData } from '../Event/SyncEvent'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { InternalEventHandlerInterface } from '../Internal/InternalEventHandlerInterface'
import { InternalEventInterface } from '../Internal/InternalEventInterface'
import { AbstractService } from '../Service/AbstractService'
import { UserEventServiceEventPayload, UserEventServiceEvent } from './UserEventServiceEvent'
export class UserEventService
extends AbstractService<UserEventServiceEvent, UserEventServiceEventPayload>
implements InternalEventHandlerInterface
{
constructor(internalEventBus: InternalEventBusInterface) {
super(internalEventBus)
internalEventBus.addEventHandler(this, SyncEvent.ReceivedUserEvents)
}
async handleEvent(event: InternalEventInterface): Promise<void> {
if (event.type === SyncEvent.ReceivedUserEvents) {
return this.handleReceivedUserEvents(event.payload as SyncEventReceivedUserEventsData)
}
}
private async handleReceivedUserEvents(userEvents: UserEventServerHash[]): Promise<void> {
if (userEvents.length === 0) {
return
}
for (const serverEvent of userEvents) {
const serviceEvent: UserEventServiceEventPayload = {
eventPayload: JSON.parse(serverEvent.event_payload),
}
await this.notifyEventSync(UserEventServiceEvent.UserEventReceived, serviceEvent)
}
}
}

View File

@@ -0,0 +1,9 @@
import { UserEventPayload } from '@standardnotes/responses'
export enum UserEventServiceEvent {
UserEventReceived = 'UserEventReceived',
}
export type UserEventServiceEventPayload = {
eventPayload: UserEventPayload
}

View File

@@ -0,0 +1,10 @@
import { KeySystemRootKeyPasswordType, KeySystemRootKeyStorageMode, VaultListingInterface } from '@standardnotes/models'
export type ChangeVaultOptionsDTO = {
vault: VaultListingInterface
newPasswordType:
| { passwordType: KeySystemRootKeyPasswordType.Randomized }
| { passwordType: KeySystemRootKeyPasswordType.UserInputted; userInputtedPassword: string }
| undefined
newKeyStorageMode: KeySystemRootKeyStorageMode | undefined
}

View File

@@ -0,0 +1,150 @@
import { MutatorClientInterface, SyncServiceInterface } from '@standardnotes/services'
import { ItemManagerInterface } from '../../Item/ItemManagerInterface'
import {
KeySystemRootKeyPasswordType,
KeySystemRootKeyStorageMode,
VaultListingInterface,
VaultListingMutator,
} from '@standardnotes/models'
import { EncryptionProviderInterface, KeySystemKeyManagerInterface } from '@standardnotes/encryption'
import { ChangeVaultOptionsDTO } from '../ChangeVaultOptionsDTO'
import { GetVaultUseCase } from './GetVault'
import { assert } from '@standardnotes/utils'
export class ChangeVaultKeyOptionsUseCase {
constructor(
private items: ItemManagerInterface,
private mutator: MutatorClientInterface,
private sync: SyncServiceInterface,
private encryption: EncryptionProviderInterface,
) {}
private get keys(): KeySystemKeyManagerInterface {
return this.encryption.keys
}
async execute(dto: ChangeVaultOptionsDTO): Promise<void> {
const useStorageMode = dto.newKeyStorageMode ?? dto.vault.keyStorageMode
if (dto.newPasswordType) {
if (dto.vault.keyPasswordType === dto.newPasswordType.passwordType) {
throw new Error('Vault password type is already set to this type')
}
if (dto.newPasswordType.passwordType === KeySystemRootKeyPasswordType.UserInputted) {
if (!dto.newPasswordType.userInputtedPassword) {
throw new Error('User inputted password is required')
}
await this.changePasswordTypeToUserInputted(dto.vault, dto.newPasswordType.userInputtedPassword, useStorageMode)
} else if (dto.newPasswordType.passwordType === KeySystemRootKeyPasswordType.Randomized) {
await this.changePasswordTypeToRandomized(dto.vault, useStorageMode)
}
}
if (dto.newKeyStorageMode) {
const usecase = new GetVaultUseCase(this.items)
const latestVault = usecase.execute({ keySystemIdentifier: dto.vault.systemIdentifier })
assert(latestVault)
if (latestVault.rootKeyParams.passwordType !== KeySystemRootKeyPasswordType.UserInputted) {
throw new Error('Vault uses randomized password and cannot change its storage preference')
}
if (dto.newKeyStorageMode === latestVault.keyStorageMode) {
throw new Error('Vault already uses this storage preference')
}
if (
dto.newKeyStorageMode === KeySystemRootKeyStorageMode.Local ||
dto.newKeyStorageMode === KeySystemRootKeyStorageMode.Ephemeral
) {
await this.changeStorageModeToLocalOrEphemeral(latestVault, dto.newKeyStorageMode)
} else if (dto.newKeyStorageMode === KeySystemRootKeyStorageMode.Synced) {
await this.changeStorageModeToSynced(latestVault)
}
}
await this.sync.sync()
}
private async changePasswordTypeToUserInputted(
vault: VaultListingInterface,
userInputtedPassword: string,
storageMode: KeySystemRootKeyStorageMode,
): Promise<void> {
const newRootKey = this.encryption.createUserInputtedKeySystemRootKey({
systemIdentifier: vault.systemIdentifier,
userInputtedPassword: userInputtedPassword,
})
if (storageMode === KeySystemRootKeyStorageMode.Synced) {
await this.mutator.insertItem(newRootKey, true)
} else {
this.encryption.keys.intakeNonPersistentKeySystemRootKey(newRootKey, storageMode)
}
await this.mutator.changeItem<VaultListingMutator>(vault, (mutator) => {
mutator.rootKeyParams = newRootKey.keyParams
})
await this.encryption.reencryptKeySystemItemsKeysForVault(vault.systemIdentifier)
}
private async changePasswordTypeToRandomized(
vault: VaultListingInterface,
storageMode: KeySystemRootKeyStorageMode,
): Promise<void> {
const newRootKey = this.encryption.createRandomizedKeySystemRootKey({
systemIdentifier: vault.systemIdentifier,
})
if (storageMode !== KeySystemRootKeyStorageMode.Synced) {
throw new Error('Cannot change to randomized password if root key storage is not synced')
}
await this.mutator.changeItem<VaultListingMutator>(vault, (mutator) => {
mutator.rootKeyParams = newRootKey.keyParams
})
await this.mutator.insertItem(newRootKey, true)
await this.encryption.reencryptKeySystemItemsKeysForVault(vault.systemIdentifier)
}
private async changeStorageModeToLocalOrEphemeral(
vault: VaultListingInterface,
newKeyStorageMode: KeySystemRootKeyStorageMode,
): Promise<void> {
const primaryKey = this.keys.getPrimaryKeySystemRootKey(vault.systemIdentifier)
if (!primaryKey) {
throw new Error('No primary key found')
}
this.keys.intakeNonPersistentKeySystemRootKey(primaryKey, newKeyStorageMode)
await this.keys.deleteAllSyncedKeySystemRootKeys(vault.systemIdentifier)
await this.mutator.changeItem<VaultListingMutator>(vault, (mutator) => {
mutator.keyStorageMode = newKeyStorageMode
})
await this.sync.sync()
}
private async changeStorageModeToSynced(vault: VaultListingInterface): Promise<void> {
const allRootKeys = this.keys.getAllKeySystemRootKeysForVault(vault.systemIdentifier)
const syncedRootKeys = this.keys.getSyncedKeySystemRootKeysForVault(vault.systemIdentifier)
for (const key of allRootKeys) {
const existingSyncedKey = syncedRootKeys.find((syncedKey) => syncedKey.token === key.token)
if (existingSyncedKey) {
continue
}
await this.mutator.insertItem(key)
}
await this.mutator.changeItem<VaultListingMutator>(vault, (mutator) => {
mutator.keyStorageMode = KeySystemRootKeyStorageMode.Synced
})
}
}

View File

@@ -0,0 +1,115 @@
import { SyncServiceInterface } from './../../Sync/SyncServiceInterface'
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import { UuidGenerator } from '@standardnotes/utils'
import {
KeySystemRootKeyParamsInterface,
KeySystemRootKeyPasswordType,
VaultListingContentSpecialized,
VaultListingInterface,
KeySystemRootKeyStorageMode,
FillItemContentSpecialized,
KeySystemRootKeyInterface,
} from '@standardnotes/models'
import { ContentType } from '@standardnotes/common'
import { MutatorClientInterface } from '../../Mutator/MutatorClientInterface'
export class CreateVaultUseCase {
constructor(
private mutator: MutatorClientInterface,
private encryption: EncryptionProviderInterface,
private sync: SyncServiceInterface,
) {}
async execute(dto: {
vaultName: string
vaultDescription?: string
userInputtedPassword: string | undefined
storagePreference: KeySystemRootKeyStorageMode
}): Promise<VaultListingInterface> {
const keySystemIdentifier = UuidGenerator.GenerateUuid()
const rootKey = await this.createKeySystemRootKey({
keySystemIdentifier,
vaultName: dto.vaultName,
vaultDescription: dto.vaultDescription,
userInputtedPassword: dto.userInputtedPassword,
storagePreference: dto.storagePreference,
})
await this.createKeySystemItemsKey(keySystemIdentifier, rootKey.token)
const vaultListing = await this.createVaultListing({
keySystemIdentifier,
vaultName: dto.vaultName,
vaultDescription: dto.vaultDescription,
passwordType: dto.userInputtedPassword
? KeySystemRootKeyPasswordType.UserInputted
: KeySystemRootKeyPasswordType.Randomized,
rootKeyParams: rootKey.keyParams,
storage: dto.storagePreference,
})
await this.sync.sync()
return vaultListing
}
private async createVaultListing(dto: {
keySystemIdentifier: string
vaultName: string
vaultDescription?: string
passwordType: KeySystemRootKeyPasswordType
rootKeyParams: KeySystemRootKeyParamsInterface
storage: KeySystemRootKeyStorageMode
}): Promise<VaultListingInterface> {
const content: VaultListingContentSpecialized = {
systemIdentifier: dto.keySystemIdentifier,
rootKeyParams: dto.rootKeyParams,
keyStorageMode: dto.storage,
name: dto.vaultName,
description: dto.vaultDescription,
}
return this.mutator.createItem(ContentType.VaultListing, FillItemContentSpecialized(content), true)
}
private async createKeySystemItemsKey(keySystemIdentifier: string, rootKeyToken: string): Promise<void> {
const keySystemItemsKey = this.encryption.createKeySystemItemsKey(
UuidGenerator.GenerateUuid(),
keySystemIdentifier,
undefined,
rootKeyToken,
)
await this.mutator.insertItem(keySystemItemsKey)
}
private async createKeySystemRootKey(dto: {
keySystemIdentifier: string
vaultName: string
vaultDescription?: string
userInputtedPassword: string | undefined
storagePreference: KeySystemRootKeyStorageMode
}): Promise<KeySystemRootKeyInterface> {
let newRootKey: KeySystemRootKeyInterface | undefined
if (dto.userInputtedPassword) {
newRootKey = this.encryption.createUserInputtedKeySystemRootKey({
systemIdentifier: dto.keySystemIdentifier,
userInputtedPassword: dto.userInputtedPassword,
})
} else {
newRootKey = this.encryption.createRandomizedKeySystemRootKey({
systemIdentifier: dto.keySystemIdentifier,
})
}
if (dto.storagePreference === KeySystemRootKeyStorageMode.Synced) {
await this.mutator.insertItem(newRootKey, true)
} else {
this.encryption.keys.intakeNonPersistentKeySystemRootKey(newRootKey, dto.storagePreference)
}
return newRootKey
}
}

Some files were not shown because too many files have changed in this diff Show More